From cf67af3b14f1d804f909263a003590579e5b9735 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 31 Jan 2026 15:02:03 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20subscription=20limits?= =?UTF-8?q?=20and=20UI=20tags=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- controller/subscription.go | 33 ++- controller/subscription_payment_creem.go | 12 + controller/subscription_payment_epay.go | 11 + controller/subscription_payment_stripe.go | 12 + model/subscription.go | 27 ++ .../subscriptions/SubscriptionsColumnDefs.jsx | 35 ++- .../modals/AddEditSubscriptionModal.jsx | 14 + .../topup/SubscriptionPlansCard.jsx | 270 +++++++++++------- .../modals/SubscriptionPurchaseModal.jsx | 19 +- 9 files changed, 307 insertions(+), 126 deletions(-) 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} />