feat: Add subscription limits and UI tags consistency

Add per-plan purchase limits with backend enforcement and UI disable states.
Expose limit configuration in admin plan editor and show limits in plan tables/cards.
Refine subscription UI tags with unified badge style and streamlined “My Subscriptions” layout.
This commit is contained in:
t0ng7u
2026-01-31 15:02:03 +08:00
parent 2297af731c
commit cf67af3b14
9 changed files with 307 additions and 126 deletions

View File

@@ -134,6 +134,10 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
req.Plan.DurationValue = 1
}
if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
@@ -201,6 +205,10 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
req.Plan.DurationValue = 1
}
if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
@@ -215,18 +223,19 @@ 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,
"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,
"updated_at": common.GetTimestamp(),
}
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
return err

View File

@@ -56,6 +56,18 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
userId := c.GetInt("id")
user, _ := model.GetUserById(userId, false)
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
if err != nil {
common.ApiError(c, err)
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
return
}
}
reference := "sub-creem-ref-" + randstr.String(6)
referenceId := "sub_ref_" + common.Sha1([]byte(reference+time.Now().String()+user.Username))

View File

@@ -48,6 +48,17 @@ func SubscriptionRequestEpay(c *gin.Context) {
}
userId := c.GetInt("id")
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
if err != nil {
common.ApiError(c, err)
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
return
}
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/return")

View File

@@ -53,6 +53,18 @@ func SubscriptionRequestStripePay(c *gin.Context) {
userId := c.GetInt("id")
user, _ := model.GetUserById(userId, false)
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
if err != nil {
common.ApiError(c, err)
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
return
}
}
reference := fmt.Sprintf("sub-stripe-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
referenceId := "sub_ref_" + common.Sha1([]byte(reference))

View File

@@ -204,6 +204,9 @@ type SubscriptionPlan struct {
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
// Max purchases per user (0 = unlimited)
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
// Quota reset period for plan items
QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:'never'"`
QuotaResetCustomSeconds int64 `json:"quota_reset_custom_seconds" gorm:"type:bigint;default:0"`
@@ -438,6 +441,19 @@ func GetSubscriptionPlanItems(planId int) ([]SubscriptionPlanItem, error) {
return items, nil
}
func CountUserSubscriptionsByPlan(userId int, planId int) (int64, error) {
if userId <= 0 || planId <= 0 {
return 0, errors.New("invalid userId or planId")
}
var count int64
if err := DB.Model(&UserSubscription{}).
Where("user_id = ? AND plan_id = ?", userId, planId).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) {
if tx == nil {
return nil, errors.New("tx is nil")
@@ -448,6 +464,17 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
if userId <= 0 {
return nil, errors.New("invalid user id")
}
if plan.MaxPurchasePerUser > 0 {
var count int64
if err := tx.Model(&UserSubscription{}).
Where("user_id = ? AND plan_id = ?", userId, plan.Id).
Count(&count).Error; err != nil {
return nil, err
}
if count >= int64(plan.MaxPurchasePerUser) {
return nil, errors.New("已达到该套餐购买上限")
}
}
nowUnix := GetDBTimestamp()
now := time.Unix(nowUnix, 0)
endUnix, err := calcPlanEndTime(now, plan)

View File

@@ -26,6 +26,7 @@ import {
Typography,
Popover,
Divider,
Badge,
} from '@douyinfe/semi-ui';
import { IconEdit, IconStop, IconPlay } from '@douyinfe/semi-icons';
import { convertUSDToCurrency } from '../../../helpers/render';
@@ -83,6 +84,12 @@ const renderPlanTitle = (text, record, t) => {
<Text strong style={{ color: 'var(--semi-color-success)' }}>
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
</Text>
<Text type='tertiary'>{t('购买上限')}</Text>
<Text>
{plan?.max_purchase_per_user > 0
? plan.max_purchase_per_user
: t('不限')}
</Text>
<Text type='tertiary'>{t('有效期')}</Text>
<Text>{formatDuration(plan, t)}</Text>
<Text type='tertiary'>{t('重置')}</Text>
@@ -123,17 +130,36 @@ const renderPrice = (text) => {
);
};
const renderPurchaseLimit = (text, record, t) => {
const limit = Number(record?.plan?.max_purchase_per_user || 0);
return (
<Text type={limit > 0 ? 'secondary' : 'tertiary'}>
{limit > 0 ? limit : t('不限')}
</Text>
);
};
const renderDuration = (text, record, t) => {
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
};
const renderEnabled = (text, record, t) => {
return text ? (
<Tag color='green' shape='circle'>
<Tag
color='white'
shape='circle'
type='light'
prefixIcon={<Badge dot type='success' />}
>
{t('启用')}
</Tag>
) : (
<Tag color='grey' shape='circle'>
<Tag
color='white'
shape='circle'
type='light'
prefixIcon={<Badge dot type='danger' />}
>
{t('禁用')}
</Tag>
);
@@ -300,6 +326,11 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
width: 100,
render: (text) => renderPrice(text),
},
{
title: t('购买上限'),
width: 90,
render: (text, record) => renderPurchaseLimit(text, record, t),
},
{
title: t('优先级'),
dataIndex: ['plan', 'sort_order'],

View File

@@ -94,6 +94,7 @@ const AddEditSubscriptionModal = ({
quota_reset_custom_seconds: 0,
enabled: true,
sort_order: 0,
max_purchase_per_user: 0,
stripe_price_id: '',
creem_product_id: '',
});
@@ -122,6 +123,7 @@ const AddEditSubscriptionModal = ({
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
enabled: p.enabled !== false,
sort_order: Number(p.sort_order || 0),
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
stripe_price_id: p.stripe_price_id || '',
creem_product_id: p.creem_product_id || '',
};
@@ -283,6 +285,7 @@ const AddEditSubscriptionModal = ({
? Number(values.quota_reset_custom_seconds || 0)
: 0,
sort_order: Number(values.sort_order || 0),
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
},
items: cleanedItems,
};
@@ -485,6 +488,17 @@ const AddEditSubscriptionModal = ({
/>
</Col>
<Col span={12}>
<Form.InputNumber
field='max_purchase_per_user'
label={t('购买上限')}
min={0}
precision={0}
extraText={t('0 表示不限')}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<Form.Switch
field='enabled'

View File

@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useMemo, useState } from 'react';
import {
Avatar,
Badge,
Button,
Card,
Divider,
@@ -27,10 +28,11 @@ import {
Skeleton,
Space,
Tag,
Tooltip,
Typography,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../helpers';
import { getCurrencyConfig } from '../../helpers/render';
import { getCurrencyConfig, stringToColor } from '../../helpers/render';
import { CalendarClock, Check, Crown, RefreshCw, Sparkles } from 'lucide-react';
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
@@ -220,6 +222,19 @@ const SubscriptionPlansCard = ({
const hasActiveSubscription = activeSubscriptions.length > 0;
const hasAnySubscription = allSubscriptions.length > 0;
const planPurchaseCountMap = useMemo(() => {
const map = new Map();
(allSubscriptions || []).forEach((sub) => {
const planId = sub?.subscription?.plan_id;
if (!planId) return;
map.set(planId, (map.get(planId) || 0) + 1);
});
return map;
}, [allSubscriptions]);
const getPlanPurchaseCount = (planId) =>
planPurchaseCountMap.get(planId) || 0;
// 计算单个订阅的剩余天数
const getRemainingDays = (sub) => {
if (!sub?.subscription?.end_time) return 0;
@@ -318,16 +333,21 @@ const SubscriptionPlansCard = ({
<div className='flex items-center gap-2'>
<Text strong>{t('我的订阅')}</Text>
{hasActiveSubscription ? (
<Tag color='green' size='small' shape='circle'>
<Tag
color='white'
size='small'
shape='circle'
prefixIcon={<Badge dot type='success' />}
>
{activeSubscriptions.length} {t('个生效中')}
</Tag>
) : (
<Tag color='grey' size='small' shape='circle'>
<Tag color='white' size='small' shape='circle'>
{t('无生效')}
</Tag>
)}
{allSubscriptions.length > activeSubscriptions.length && (
<Tag color='grey' size='small' shape='circle' type='light'>
<Tag color='white' size='small' shape='circle'>
{allSubscriptions.length - activeSubscriptions.length}{' '}
{t('个已过期')}
</Tag>
@@ -348,97 +368,90 @@ const SubscriptionPlansCard = ({
</div>
{hasAnySubscription ? (
<div className='space-y-3 max-h-64 overflow-y-auto'>
{allSubscriptions.map((sub, subIndex) => {
const subscription = sub.subscription;
const items = sub.items || [];
const remainDays = getRemainingDays(sub);
const usagePercent = getUsagePercent(sub);
const now = Date.now() / 1000;
const isExpired = (subscription?.end_time || 0) < now;
const isActive =
subscription?.status === 'active' && !isExpired;
<>
<Divider margin={8} />
<div className='max-h-64 overflow-y-auto pr-1 semi-table-body'>
{allSubscriptions.map((sub, subIndex) => {
const isLast = subIndex === allSubscriptions.length - 1;
const subscription = sub.subscription;
const items = sub.items || [];
const remainDays = getRemainingDays(sub);
const usagePercent = getUsagePercent(sub);
const now = Date.now() / 1000;
const isExpired = (subscription?.end_time || 0) < now;
const isActive =
subscription?.status === 'active' && !isExpired;
return (
<div
key={subscription?.id || subIndex}
className={`p-2 rounded-lg ${isActive ? 'bg-green-50' : 'bg-gray-100 opacity-70'}`}
>
{/* 订阅概要 */}
<div className='flex items-center justify-between text-xs mb-2'>
<div className='flex items-center gap-2'>
<span className='font-medium'>
{t('订阅')} #{subscription?.id}
</span>
{isActive ? (
<Tag color='green' size='small' shape='circle'>
{t('生效')}
</Tag>
) : (
<Tag color='grey' size='small' shape='circle'>
{t('已过期')}
</Tag>
)}
</div>
{isActive && (
<span className='text-gray-500'>
{t('剩余')} {remainDays} {t('天')} · {t('已用')}{' '}
{usagePercent}%
</span>
)}
</div>
<div className='text-xs text-gray-500 mb-2'>
{isActive ? t('至') : t('过期于')}{' '}
{new Date(
(subscription?.end_time || 0) * 1000,
).toLocaleString()}
</div>
{/* 权益列表 */}
{items.length > 0 && (
<div className='flex flex-wrap gap-1'>
{items.slice(0, 4).map((it) => {
const used = Number(it.amount_used || 0);
const total = Number(it.amount_total || 0);
const remain = total - used;
const percent =
total > 0 ? Math.round((used / total) * 100) : 0;
const label = it.quota_type === 1 ? t('次') : '';
return (
return (
<div key={subscription?.id || subIndex}>
{/* 订阅概要 */}
<div className='flex items-center justify-between text-xs mb-2'>
<div className='flex items-center gap-2'>
<span className='font-medium'>
{t('订阅')} #{subscription?.id}
</span>
{isActive ? (
<Tag
key={`${it.id}-${it.model_name}`}
color='white'
size='small'
color={
isActive
? percent > 80
? 'red'
: 'blue'
: 'grey'
}
type='light'
shape='circle'
prefixIcon={<Badge dot type='success' />}
>
{it.model_name}: {remain}
{label}
{t('生效')}
</Tag>
);
})}
{items.length > 4 && (
<Tag
size='small'
color='grey'
type='light'
shape='circle'
>
+{items.length - 4}
</Tag>
) : (
<Tag color='white' size='small' shape='circle'>
{t('已过期')}
</Tag>
)}
</div>
{isActive && (
<span className='text-gray-500'>
{t('剩余')} {remainDays} {t('天')} · {t('已用')}{' '}
{usagePercent}%
</span>
)}
</div>
)}
</div>
);
})}
</div>
<div className='text-xs text-gray-500 mb-2'>
{isActive ? t('至') : t('过期于')}{' '}
{new Date(
(subscription?.end_time || 0) * 1000,
).toLocaleString()}
</div>
{/* 权益列表 */}
{items.length > 0 && (
<div className='flex flex-wrap gap-1'>
{items.slice(0, 4).map((it) => {
const used = Number(it.amount_used || 0);
const total = Number(it.amount_total || 0);
const remain = total - used;
const label = it.quota_type === 1 ? t('次') : '';
return (
<Tag
key={`${it.id}-${it.model_name}`}
size='small'
color='white'
shape='circle'
>
{it.model_name}: {remain}
{label}
</Tag>
);
})}
{items.length > 4 && (
<Tag size='small' color='white' shape='circle'>
+{items.length - 4}
</Tag>
)}
</div>
)}
{!isLast && <Divider margin={12} />}
</div>
);
})}
</div>
</>
) : (
<div className='text-xs text-gray-500'>
{t('购买套餐后即可享受模型权益')}
@@ -458,6 +471,15 @@ const SubscriptionPlansCard = ({
price % 1 === 0 ? 0 : 2,
);
const isPopular = index === 0 && plans.length > 1;
const limit = Number(plan?.max_purchase_per_user || 0);
const limitLabel =
limit > 0 ? `${t('限购')} ${limit}` : t('不限购');
const planTags = [
`${t('有效期')}: ${formatDuration(plan, t)}`,
`${t('重置')}: ${formatResetPeriod(plan, t)}`,
`${t('权益')}: ${planItems.length} ${t('项')}`,
limitLabel,
];
return (
<Card
@@ -508,13 +530,20 @@ const SubscriptionPlansCard = ({
{displayPrice}
</span>
</div>
<div className='text-sm text-gray-500 mt-1'>
<CalendarClock size={12} className='inline mr-1' />
{formatDuration(plan, t)}
<span className='ml-2 text-xs text-gray-400'>
{t('重置')}: {formatResetPeriod(plan, t)}
</span>
</div>
</div>
{/* 属性标签 */}
<div className='flex flex-wrap justify-center gap-2 pb-2'>
{planTags.map((tag) => (
<Tag
key={tag}
size='small'
shape='circle'
color='white'
>
{tag}
</Tag>
))}
</div>
<Divider margin={12} />
@@ -530,12 +559,7 @@ const SubscriptionPlansCard = ({
<span className='truncate flex-1'>
{it.model_name}
</span>
<Tag
size='small'
color='blue'
shape='circle'
type='light'
>
<Tag size='small' color='white' shape='circle'>
{it.amount_total}
{it.quota_type === 1 ? t('次') : ''}
</Tag>
@@ -554,17 +578,33 @@ const SubscriptionPlansCard = ({
</div>
{/* 购买按钮 */}
<Button
theme='solid'
type='primary'
block
onClick={() => openBuy(p)}
className={
isPopular ? '!bg-purple-600 hover:!bg-purple-700' : ''
}
>
{t('立即订阅')}
</Button>
{(() => {
const count = getPlanPurchaseCount(p?.plan?.id);
const reached = limit > 0 && count >= limit;
const tip = reached
? t('已达到购买上限') + ` (${count}/${limit})`
: '';
const buttonEl = (
<Button
theme='outline'
type='tertiary'
block
disabled={reached}
onClick={() => {
if (!reached) openBuy(p);
}}
>
{reached ? t('已达上限') : t('立即订阅')}
</Button>
);
return reached ? (
<Tooltip content={tip} position='top'>
{buttonEl}
</Tooltip>
) : (
buttonEl
);
})()}
</div>
</Card>
);
@@ -591,6 +631,14 @@ const SubscriptionPlansCard = ({
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
enableCreemTopUp={enableCreemTopUp}
purchaseLimitInfo={
selectedPlan?.plan?.id
? {
limit: Number(selectedPlan?.plan?.max_purchase_per_user || 0),
count: getPlanPurchaseCount(selectedPlan?.plan?.id),
}
: null
}
onPayStripe={payStripe}
onPayCreem={payCreem}
onPayEpay={payEpay}

View File

@@ -83,6 +83,7 @@ const SubscriptionPurchaseModal = ({
enableOnlineTopUp = false,
enableStripeTopUp = false,
enableCreemTopUp = false,
purchaseLimitInfo = null,
onPayStripe,
onPayCreem,
onPayEpay,
@@ -97,6 +98,10 @@ const SubscriptionPurchaseModal = ({
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
const hasEpay = enableOnlineTopUp && epayMethods.length > 0;
const hasAnyPayment = hasStripe || hasCreem || hasEpay;
const purchaseLimit = Number(purchaseLimitInfo?.limit || 0);
const purchaseCount = Number(purchaseLimitInfo?.count || 0);
const purchaseLimitReached =
purchaseLimit > 0 && purchaseCount >= purchaseLimit;
return (
<Modal
@@ -203,6 +208,15 @@ const SubscriptionPurchaseModal = ({
)}
{/* 支付方式 */}
{purchaseLimitReached && (
<Banner
type='warning'
description={`${t('已达到购买上限')} (${purchaseCount}/${purchaseLimit})`}
className='!rounded-xl'
closeIcon={null}
/>
)}
{hasAnyPayment ? (
<div className='space-y-3'>
<Text size='small' type='tertiary'>
@@ -219,6 +233,7 @@ const SubscriptionPurchaseModal = ({
icon={<SiStripe size={14} color='#635BFF' />}
onClick={onPayStripe}
loading={paying}
disabled={purchaseLimitReached}
>
Stripe
</Button>
@@ -230,6 +245,7 @@ const SubscriptionPurchaseModal = ({
icon={<IconCreditCard />}
onClick={onPayCreem}
loading={paying}
disabled={purchaseLimitReached}
>
Creem
</Button>
@@ -250,13 +266,14 @@ const SubscriptionPurchaseModal = ({
value: m.type,
label: m.name || m.type,
}))}
disabled={purchaseLimitReached}
/>
<Button
theme='solid'
type='primary'
onClick={onPayEpay}
loading={paying}
disabled={!selectedEpayMethod}
disabled={!selectedEpayMethod || purchaseLimitReached}
>
{t('支付')}
</Button>