diff --git a/controller/subscription.go b/controller/subscription.go index b4561fe45..574affd8d 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -32,6 +32,18 @@ func normalizeBillingPreference(pref string) string { } } +func normalizeQuotaResetPeriod(period string) string { + switch strings.TrimSpace(period) { + case model.SubscriptionResetDaily, + model.SubscriptionResetWeekly, + model.SubscriptionResetMonthly, + model.SubscriptionResetCustom: + return strings.TrimSpace(period) + default: + return model.SubscriptionResetNever + } +} + // ---- User APIs ---- func GetSubscriptionPlans(c *gin.Context) { @@ -143,6 +155,11 @@ func AdminCreateSubscriptionPlan(c *gin.Context) { if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom { req.Plan.DurationValue = 1 } + req.Plan.QuotaResetPeriod = normalizeQuotaResetPeriod(req.Plan.QuotaResetPeriod) + if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { + common.ApiErrorMsg(c, "自定义重置周期需大于0秒") + return + } if len(req.Items) == 0 { common.ApiErrorMsg(c, "套餐至少需要配置一个模型权益") @@ -203,6 +220,11 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom { req.Plan.DurationValue = 1 } + req.Plan.QuotaResetPeriod = normalizeQuotaResetPeriod(req.Plan.QuotaResetPeriod) + if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { + common.ApiErrorMsg(c, "自定义重置周期需大于0秒") + return + } if len(req.Items) == 0 { common.ApiErrorMsg(c, "套餐至少需要配置一个模型权益") diff --git a/model/subscription.go b/model/subscription.go index fcbd4d00b..eda7475cb 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -18,6 +19,15 @@ const ( SubscriptionDurationCustom = "custom" ) +// Subscription quota reset period +const ( + SubscriptionResetNever = "never" + SubscriptionResetDaily = "daily" + SubscriptionResetWeekly = "weekly" + SubscriptionResetMonthly = "monthly" + SubscriptionResetCustom = "custom" +) + // Subscription plan type SubscriptionPlan struct { Id int `json:"id"` @@ -39,6 +49,10 @@ 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:''"` + // 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"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` UpdatedAt int64 `json:"updated_at" gorm:"bigint"` } @@ -140,6 +154,8 @@ type UserSubscriptionItem struct { QuotaType int `json:"quota_type" gorm:"type:int;index"` AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"` AmountUsed int64 `json:"amount_used" gorm:"type:bigint;not null;default:0"` + LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"` + NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"` } type SubscriptionSummary struct { @@ -173,6 +189,45 @@ func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) { } } +func normalizeResetPeriod(period string) string { + switch strings.TrimSpace(period) { + case SubscriptionResetDaily, SubscriptionResetWeekly, SubscriptionResetMonthly, SubscriptionResetCustom: + return strings.TrimSpace(period) + default: + return SubscriptionResetNever + } +} + +func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) int64 { + if plan == nil { + return 0 + } + period := normalizeResetPeriod(plan.QuotaResetPeriod) + if period == SubscriptionResetNever { + return 0 + } + var next time.Time + switch period { + case SubscriptionResetDaily: + next = base.Add(24 * time.Hour) + case SubscriptionResetWeekly: + next = base.AddDate(0, 0, 7) + case SubscriptionResetMonthly: + next = base.AddDate(0, 1, 0) + case SubscriptionResetCustom: + if plan.QuotaResetCustomSeconds <= 0 { + return 0 + } + next = base.Add(time.Duration(plan.QuotaResetCustomSeconds) * time.Second) + default: + return 0 + } + if endUnix > 0 && next.Unix() > endUnix { + return 0 + } + return next.Unix() +} + func GetSubscriptionPlanById(id int) (*SubscriptionPlan, error) { if id <= 0 { return nil, errors.New("invalid plan id") @@ -210,6 +265,12 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio if err != nil { return nil, err } + resetBase := now + nextReset := calcNextResetTime(resetBase, plan, endUnix) + lastReset := int64(0) + if nextReset > 0 { + lastReset = now.Unix() + } sub := &UserSubscription{ UserId: userId, PlanId: plan.Id, @@ -238,6 +299,8 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio QuotaType: it.QuotaType, AmountTotal: it.AmountTotal, AmountUsed: 0, + LastResetTime: lastReset, + NextResetTime: nextReset, }) } if err := tx.Create(&userItems).Error; err != nil { @@ -476,6 +539,52 @@ type SubscriptionPreConsumeResult struct { AmountUsedAfter int64 } +func maybeResetSubscriptionItemTx(tx *gorm.DB, item *UserSubscriptionItem, now int64) error { + if tx == nil || item == nil { + return errors.New("invalid reset args") + } + if item.NextResetTime > 0 && item.NextResetTime > now { + return nil + } + var sub UserSubscription + if err := tx.Where("id = ?", item.UserSubscriptionId).First(&sub).Error; err != nil { + return err + } + var plan SubscriptionPlan + if err := tx.Where("id = ?", sub.PlanId).First(&plan).Error; err != nil { + return err + } + if normalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever { + return nil + } + + baseUnix := item.LastResetTime + if baseUnix <= 0 { + baseUnix = sub.StartTime + } + base := time.Unix(baseUnix, 0) + next := calcNextResetTime(base, &plan, sub.EndTime) + advanced := false + for next > 0 && next <= now { + advanced = true + base = time.Unix(next, 0) + next = calcNextResetTime(base, &plan, sub.EndTime) + } + if !advanced { + // keep next reset time in sync if missing + if item.NextResetTime == 0 && next > 0 { + item.NextResetTime = next + item.LastResetTime = base.Unix() + return tx.Save(item).Error + } + return nil + } + item.AmountUsed = 0 + item.LastResetTime = base.Unix() + item.NextResetTime = next + return tx.Save(item).Error +} + // PreConsumeUserSubscription finds a valid active subscription item and increments amount_used. // quotaType=0 => consume quota units; quotaType=1 => consume request count (usually 1). func PreConsumeUserSubscription(userId int, modelName string, quotaType int, amount int64) (*SubscriptionPreConsumeResult, error) { @@ -504,6 +613,13 @@ func PreConsumeUserSubscription(userId int, modelName string, quotaType int, amo if err := q.First(&item).Error; err != nil { return errors.New("no active subscription item for this model") } + if err := maybeResetSubscriptionItemTx(tx, &item, now); err != nil { + return err + } + // reload item after potential reset + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", item.Id).First(&item).Error; err != nil { + return err + } usedBefore := item.AmountUsed remain := item.AmountTotal - usedBefore if remain < amount { diff --git a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx index cb0f24565..41b9c46d9 100644 --- a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx +++ b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx @@ -43,7 +43,7 @@ import { IconPlusCircle, IconSave, } from '@douyinfe/semi-icons'; -import { Trash2, Clock, Boxes } from 'lucide-react'; +import { Trash2, Clock, Boxes, RefreshCw } from 'lucide-react'; import { API, showError, showSuccess } from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; @@ -57,6 +57,14 @@ const durationUnitOptions = [ { value: 'custom', label: '自定义(秒)' }, ]; +const resetPeriodOptions = [ + { value: 'never', label: '不重置' }, + { value: 'daily', label: '每天' }, + { value: 'weekly', label: '每周' }, + { value: 'monthly', label: '每月' }, + { value: 'custom', label: '自定义(秒)' }, +]; + const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量'); const AddEditSubscriptionModal = ({ @@ -82,6 +90,8 @@ const AddEditSubscriptionModal = ({ duration_unit: 'month', duration_value: 1, custom_seconds: 0, + quota_reset_period: 'never', + quota_reset_custom_seconds: 0, enabled: true, sort_order: 0, stripe_price_id: '', @@ -108,6 +118,8 @@ const AddEditSubscriptionModal = ({ duration_unit: p.duration_unit || 'month', duration_value: Number(p.duration_value || 1), custom_seconds: Number(p.custom_seconds || 0), + quota_reset_period: p.quota_reset_period || 'never', + quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0), enabled: p.enabled !== false, sort_order: Number(p.sort_order || 0), stripe_price_id: p.stripe_price_id || '', @@ -264,6 +276,11 @@ const AddEditSubscriptionModal = ({ price_amount: Number(values.price_amount || 0), duration_value: Number(values.duration_value || 0), custom_seconds: Number(values.custom_seconds || 0), + quota_reset_period: values.quota_reset_period || 'never', + quota_reset_custom_seconds: + values.quota_reset_period === 'custom' + ? Number(values.quota_reset_custom_seconds || 0) + : 0, sort_order: Number(values.sort_order || 0), }, items: cleanedItems, @@ -539,6 +556,63 @@ const AddEditSubscriptionModal = ({ + {/* 额度重置 */} + +
+ + + +
+ + {t('额度重置')} + +
+ {t('支持周期性重置套餐权益额度')} +
+
+
+ + + + + {resetPeriodOptions.map((o) => ( + + {o.label} + + ))} + + + + {values.quota_reset_period === 'custom' ? ( + + ) : ( + + )} + + +
+ {/* 第三方支付配置 */}
diff --git a/web/src/components/topup/SubscriptionPlansCard.jsx b/web/src/components/topup/SubscriptionPlansCard.jsx index 0395cffb9..88483269c 100644 --- a/web/src/components/topup/SubscriptionPlansCard.jsx +++ b/web/src/components/topup/SubscriptionPlansCard.jsx @@ -55,6 +55,22 @@ function formatDuration(plan, 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( @@ -497,6 +513,9 @@ const SubscriptionPlansCard = ({
{formatDuration(plan, t)} + + {t('重置')}: {formatResetPeriod(plan, t)} +
diff --git a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx index a9adcec6e..4b9c4bde7 100644 --- a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx +++ b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx @@ -54,6 +54,22 @@ function formatDuration(plan, 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 getCurrencySymbol(currency) { const symbols = { USD: '$', EUR: '€', CNY: '¥', GBP: '£', JPY: '¥' }; @@ -129,6 +145,14 @@ const SubscriptionPurchaseModal = ({ +
+ + {t('重置周期')}: + + + {formatResetPeriod(plan, t)} + +
{t('包含权益')}: