Skip to content
2 changes: 2 additions & 0 deletions controller/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
"upgrade_group": req.Plan.UpgradeGroup,
"quota_reset_period": req.Plan.QuotaResetPeriod,
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"model_limits_enabled": req.Plan.ModelLimitsEnabled,
"model_limits": req.Plan.ModelLimits,
"updated_at": common.GetTimestamp(),
}
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
Expand Down
4 changes: 4 additions & 0 deletions model/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ func ensureSubscriptionPlanTableSQLite() error {
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
` + "`quota_reset_period`" + ` varchar(16) DEFAULT 'never',
` + "`quota_reset_custom_seconds`" + ` bigint DEFAULT 0,
` + "`model_limits_enabled`" + ` numeric DEFAULT 0,
` + "`model_limits`" + ` text DEFAULT '',
` + "`created_at`" + ` bigint,
` + "`updated_at`" + ` bigint,
PRIMARY KEY (` + "`id`" + `)
Expand Down Expand Up @@ -435,6 +437,8 @@ PRIMARY KEY (` + "`id`" + `)
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
{Name: "quota_reset_period", DDL: "`quota_reset_period` varchar(16) DEFAULT 'never'"},
{Name: "quota_reset_custom_seconds", DDL: "`quota_reset_custom_seconds` bigint DEFAULT 0"},
{Name: "model_limits_enabled", DDL: "`model_limits_enabled` numeric DEFAULT 0"},
{Name: "model_limits", DDL: "`model_limits` text DEFAULT ''"},
{Name: "created_at", DDL: "`created_at` bigint"},
{Name: "updated_at", DDL: "`updated_at` bigint"},
}
Expand Down
53 changes: 53 additions & 0 deletions model/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,52 @@ type SubscriptionPlan struct {
QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:'never'"`
QuotaResetCustomSeconds int64 `json:"quota_reset_custom_seconds" gorm:"type:bigint;default:0"`

// Model limits: restrict which models can be used with this plan
ModelLimitsEnabled bool `json:"model_limits_enabled" gorm:"default:false"`
ModelLimits string `json:"model_limits" gorm:"type:text"`

CreatedAt int64 `json:"created_at" gorm:"bigint"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
}

// GetModelLimits returns the model limits as a slice.
func (p *SubscriptionPlan) GetModelLimits() []string {
if p.ModelLimits == "" {
return []string{}
}
parts := strings.Split(p.ModelLimits, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}

// GetModelLimitsMap returns the model limits as a map for fast lookup.
func (p *SubscriptionPlan) GetModelLimitsMap() map[string]bool {
limits := p.GetModelLimits()
limitsMap := make(map[string]bool, len(limits))
for _, limit := range limits {
if limit != "" {
limitsMap[limit] = true
}
}
return limitsMap
}

// IsModelAllowed checks if a model is allowed by this plan's model limits.
// Returns true if model limits are disabled or if the model is in the allowed list.
func (p *SubscriptionPlan) IsModelAllowed(modelName string) bool {
if !p.ModelLimitsEnabled || p.ModelLimits == "" {
return true
}
limitsMap := p.GetModelLimitsMap()
return limitsMap[modelName]
}

func (p *SubscriptionPlan) BeforeCreate(tx *gorm.DB) error {
now := common.GetTimestamp()
p.CreatedAt = now
Expand Down Expand Up @@ -1013,12 +1055,20 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string,
if len(subs) == 0 {
return errors.New("no active subscription")
}
anyPlanAllowsModel := false
for _, candidate := range subs {
sub := candidate
plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId)
if err != nil {
return err
}
// Check model limits: skip subscriptions whose plan doesn't allow the requested model
if modelName != "" && !plan.IsModelAllowed(modelName) {
continue
}
if modelName != "" {
anyPlanAllowsModel = true
}
if err := maybeResetUserSubscriptionWithPlanTx(tx, &sub, plan, now); err != nil {
return err
}
Expand Down Expand Up @@ -1062,6 +1112,9 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string,
returnValue.AmountUsedAfter = sub.AmountUsed
return nil
}
if modelName != "" && !anyPlanAllowsModel {
return fmt.Errorf("no subscription allows model %s", modelName)
}
return fmt.Errorf("subscription quota insufficient, need=%d", amount)
})
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ const renderPlanTitle = (text, record, t) => {
<Text>{formatDuration(plan, t)}</Text>
<Text type='tertiary'>{t('重置')}</Text>
<Text>{formatResetPeriod(plan, t)}</Text>
<Text type='tertiary'>{t('模型限制')}</Text>
<Text>
{plan?.model_limits_enabled && plan?.model_limits
? `${new Set(
plan.model_limits
.split(',')
.map((item) => item.trim())
.filter(Boolean),
).size} ${t('个模型')}`
: t('不限')}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</Text>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import {
Avatar,
Button,
Expand All @@ -36,10 +36,17 @@ import {
IconCalendarClock,
IconClose,
IconCreditCard,
IconLink,
IconSave,
} from '@douyinfe/semi-icons';
import { Clock, RefreshCw } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import { AlertCircle, Clock, RefreshCw } from 'lucide-react';
import {
API,
showError,
showSuccess,
getModelCategories,
selectFilter,
} from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
Expand Down Expand Up @@ -75,6 +82,9 @@ const AddEditSubscriptionModal = ({
const [loading, setLoading] = useState(false);
const [groupOptions, setGroupOptions] = useState([]);
const [groupLoading, setGroupLoading] = useState(false);
const [models, setModels] = useState([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsLoadError, setModelsLoadError] = useState(false);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const isEdit = editingPlan?.plan?.id !== undefined;
Expand All @@ -97,12 +107,20 @@ const AddEditSubscriptionModal = ({
upgrade_group: '',
stripe_price_id: '',
creem_product_id: '',
model_limits: [],
});

const buildFormValues = () => {
const base = getInitValues();
if (editingPlan?.plan?.id === undefined) return base;
const p = editingPlan.plan || {};
let modelLimits = [];
if (p.model_limits_enabled && p.model_limits && p.model_limits !== '') {
modelLimits = p.model_limits
.split(',')
.map((m) => m.trim())
.filter(Boolean);
}
return {
...base,
title: p.title || '',
Expand All @@ -123,9 +141,48 @@ const AddEditSubscriptionModal = ({
upgrade_group: p.upgrade_group || '',
stripe_price_id: p.stripe_price_id || '',
creem_product_id: p.creem_product_id || '',
model_limits: modelLimits,
};
};

const loadModels = useCallback(async () => {
setModelsLoading(true);
setModelsLoadError(false);
try {
const res = await API.get('/api/channel/models_enabled');
const { success, data } = res.data;
if (success && Array.isArray(data)) {
const categories = getModelCategories(t);
const localModelOptions = data.map((model) => {
let icon = null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
icon = category.icon;
break;
}
}
return {
label: (
<span className='flex items-center gap-1'>
{icon}
{model}
</span>
),
value: model,
};
});
setModels(localModelOptions);
} else {
setModelsLoadError(true);
}
} catch (error) {
console.error('Failed to load models:', error);
setModelsLoadError(true);
} finally {
setModelsLoading(false);
}
}, [t]);

useEffect(() => {
if (!visible) return;
setGroupLoading(true);
Expand All @@ -139,15 +196,21 @@ const AddEditSubscriptionModal = ({
})
.catch(() => setGroupOptions([]))
.finally(() => setGroupLoading(false));
}, [visible]);
loadModels();
}, [visible, loadModels, t]);

const submit = async (values) => {
if (modelsLoadError) {
showError(t('模型列表加载失败,无法提交,请重试'));
return;
}
if (!values.title || values.title.trim() === '') {
showError(t('套餐标题不能为空'));
return;
}
setLoading(true);
try {
const modelLimitsStr = (values.model_limits || []).join(',');
const payload = {
plan: {
...values,
Expand All @@ -164,6 +227,8 @@ const AddEditSubscriptionModal = ({
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
total_amount: displayAmountToQuota(values.total_amount),
upgrade_group: values.upgrade_group || '',
model_limits_enabled: modelLimitsStr.length > 0,
model_limits: modelLimitsStr,
},
};
if (editingPlan?.plan?.id) {
Expand Down Expand Up @@ -226,6 +291,7 @@ const AddEditSubscriptionModal = ({
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
disabled={modelsLoading || modelsLoadError}
>
{t('提交')}
</Button>
Expand Down Expand Up @@ -501,6 +567,70 @@ const AddEditSubscriptionModal = ({
</Row>
</Card>

{/* 模型限制 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='cyan'
className='mr-2 shadow-md'
>
<IconLink size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('模型限制')}
</Text>
<div className='text-xs text-gray-600'>
{t('限制该订阅套餐可使用的模型范围')}
</div>
</div>
</div>

<Row gutter={12}>
<Col span={24}>
{modelsLoadError && (
<div className='mb-2 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between'>
<div className='flex items-center gap-2'>
<AlertCircle size={16} className='text-red-500' />
<Text type='danger' size='small'>
{t('模型列表加载失败,请重试')}
</Text>
</div>
<Button
size='small'
theme='light'
onClick={loadModels}
icon={<RefreshCw size={14} />}
loading={modelsLoading}
>
{t('重试')}
</Button>
</div>
)}
<Form.Select
field='model_limits'
label={t('模型限制列表')}
placeholder={t(
'请选择该套餐支持的模型,留空支持所有模型',
)}
multiple
optionList={models}
loading={modelsLoading}
disabled={modelsLoading || modelsLoadError}
extraText={t(
'选择后,通过该订阅套餐计费的请求只能使用选中的模型;留空则不限制',
)}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
showClear
style={{ width: '100%' }}
/>
</Col>
</Row>
</Card>

{/* 第三方支付配置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
Expand Down
17 changes: 17 additions & 0 deletions web/classic/src/components/topup/SubscriptionPlansCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,17 @@ const SubscriptionPlansCard = ({
formatSubscriptionResetPeriod(plan, t) === t('不重置')
? null
: `${t('额度重置')}: ${formatSubscriptionResetPeriod(plan, t)}`;
const modelLimitNames =
plan?.model_limits_enabled && plan?.model_limits
? plan.model_limits
.split(',')
.map((model) => model.trim())
.filter(Boolean)
: [];
const modelLimitsLabel =
modelLimitNames.length > 0
? `${t('可用模型')}: ${modelLimitNames.length} ${t('个模型')}`
: null;
const planBenefits = [
{
label: `${t('有效期')}: ${formatSubscriptionDuration(plan, t)}`,
Expand All @@ -523,6 +534,12 @@ const SubscriptionPlansCard = ({
: { label: totalLabel },
limitLabel ? { label: limitLabel } : null,
upgradeLabel ? { label: upgradeLabel } : null,
modelLimitsLabel
? {
label: modelLimitsLabel,
tooltip: modelLimitNames.join(', '),
}
: null,
].filter(Boolean);

return (
Expand Down
Loading