diff --git a/controller/subscription.go b/controller/subscription.go
index 4a4e86bfe..79c4f5e5b 100644
--- a/controller/subscription.go
+++ b/controller/subscription.go
@@ -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
diff --git a/controller/subscription_payment_creem.go b/controller/subscription_payment_creem.go
index bc44f660f..740ff65e7 100644
--- a/controller/subscription_payment_creem.go
+++ b/controller/subscription_payment_creem.go
@@ -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))
diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go
index b5031dcb0..830159ba3 100644
--- a/controller/subscription_payment_epay.go
+++ b/controller/subscription_payment_epay.go
@@ -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")
diff --git a/controller/subscription_payment_stripe.go b/controller/subscription_payment_stripe.go
index b7ffde46e..80a49739f 100644
--- a/controller/subscription_payment_stripe.go
+++ b/controller/subscription_payment_stripe.go
@@ -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))
diff --git a/model/subscription.go b/model/subscription.go
index 91a5d310c..336edb9d1 100644
--- a/model/subscription.go
+++ b/model/subscription.go
@@ -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)
diff --git a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx
index 9d35af434..103ef040b 100644
--- a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx
+++ b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx
@@ -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) => {
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
+ {t('购买上限')}
+
+ {plan?.max_purchase_per_user > 0
+ ? plan.max_purchase_per_user
+ : t('不限')}
+
{t('有效期')}
{formatDuration(plan, t)}
{t('重置')}
@@ -123,17 +130,36 @@ const renderPrice = (text) => {
);
};
+const renderPurchaseLimit = (text, record, t) => {
+ const limit = Number(record?.plan?.max_purchase_per_user || 0);
+ return (
+ 0 ? 'secondary' : 'tertiary'}>
+ {limit > 0 ? limit : t('不限')}
+
+ );
+};
+
const renderDuration = (text, record, t) => {
return {formatDuration(record?.plan, t)};
};
const renderEnabled = (text, record, t) => {
return text ? (
-
+ }
+ >
{t('启用')}
) : (
-
+ }
+ >
{t('禁用')}
);
@@ -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'],
diff --git a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx
index 96ef1bcd7..7465cf5df 100644
--- a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx
+++ b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx
@@ -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 = ({
/>
+
+
+
+
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 = ({
{t('我的订阅')}
{hasActiveSubscription ? (
-
+ }
+ >
{activeSubscriptions.length} {t('个生效中')}
) : (
-
+
{t('无生效')}
)}
{allSubscriptions.length > activeSubscriptions.length && (
-
+
{allSubscriptions.length - activeSubscriptions.length}{' '}
{t('个已过期')}
@@ -348,97 +368,90 @@ const SubscriptionPlansCard = ({
{hasAnySubscription ? (
-
- {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;
+ <>
+
+
+ {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 (
-
- {/* 订阅概要 */}
-
-
-
- {t('订阅')} #{subscription?.id}
-
- {isActive ? (
-
- {t('生效')}
-
- ) : (
-
- {t('已过期')}
-
- )}
-
- {isActive && (
-
- {t('剩余')} {remainDays} {t('天')} · {t('已用')}{' '}
- {usagePercent}%
-
- )}
-
-
- {isActive ? t('至') : t('过期于')}{' '}
- {new Date(
- (subscription?.end_time || 0) * 1000,
- ).toLocaleString()}
-
- {/* 权益列表 */}
- {items.length > 0 && (
-
- {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 (
+
+ {/* 订阅概要 */}
+
+
+
+ {t('订阅')} #{subscription?.id}
+
+ {isActive ? (
80
- ? 'red'
- : 'blue'
- : 'grey'
- }
- type='light'
shape='circle'
+ prefixIcon={}
>
- {it.model_name}: {remain}
- {label}
+ {t('生效')}
- );
- })}
- {items.length > 4 && (
-
- +{items.length - 4}
-
+ ) : (
+
+ {t('已过期')}
+
+ )}
+
+ {isActive && (
+
+ {t('剩余')} {remainDays} {t('天')} · {t('已用')}{' '}
+ {usagePercent}%
+
)}
- )}
-
- );
- })}
-
+
+ {isActive ? t('至') : t('过期于')}{' '}
+ {new Date(
+ (subscription?.end_time || 0) * 1000,
+ ).toLocaleString()}
+
+ {/* 权益列表 */}
+ {items.length > 0 && (
+
+ {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 (
+
+ {it.model_name}: {remain}
+ {label}
+
+ );
+ })}
+ {items.length > 4 && (
+
+ +{items.length - 4}
+
+ )}
+
+ )}
+ {!isLast &&
}
+
+ );
+ })}
+
+ >
) : (
{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 (
-
-
- {formatDuration(plan, t)}
-
- {t('重置')}: {formatResetPeriod(plan, t)}
-
-
+
+
+ {/* 属性标签 */}
+
+ {planTags.map((tag) => (
+
+ {tag}
+
+ ))}
@@ -530,12 +559,7 @@ const SubscriptionPlansCard = ({
{it.model_name}
-
+
{it.amount_total}
{it.quota_type === 1 ? t('次') : ''}
@@ -554,17 +578,33 @@ const SubscriptionPlansCard = ({
{/* 购买按钮 */}
-
+ {(() => {
+ const count = getPlanPurchaseCount(p?.plan?.id);
+ const reached = limit > 0 && count >= limit;
+ const tip = reached
+ ? t('已达到购买上限') + ` (${count}/${limit})`
+ : '';
+ const buttonEl = (
+
+ );
+ return reached ? (
+
+ {buttonEl}
+
+ ) : (
+ buttonEl
+ );
+ })()}
);
@@ -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}
diff --git a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
index a04159b9e..c7c93a616 100644
--- a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
+++ b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
@@ -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 (
+ )}
+
{hasAnyPayment ? (
@@ -219,6 +233,7 @@ const SubscriptionPurchaseModal = ({
icon={}
onClick={onPayStripe}
loading={paying}
+ disabled={purchaseLimitReached}
>
Stripe
@@ -230,6 +245,7 @@ const SubscriptionPurchaseModal = ({
icon={}
onClick={onPayCreem}
loading={paying}
+ disabled={purchaseLimitReached}
>
Creem
@@ -250,13 +266,14 @@ const SubscriptionPurchaseModal = ({
value: m.type,
label: m.name || m.type,
}))}
+ disabled={purchaseLimitReached}
/>