diff --git a/controller/subscription.go b/controller/subscription.go index 79c4f5e5b..5e20dd5b6 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -1,7 +1,6 @@ package controller import ( - "errors" "strconv" "strings" @@ -14,8 +13,7 @@ import ( // ---- Shared types ---- type SubscriptionPlanDTO struct { - Plan model.SubscriptionPlan `json:"plan"` - Items []model.SubscriptionPlanItem `json:"items"` + Plan model.SubscriptionPlan `json:"plan"` } type BillingPreferenceRequest struct { @@ -32,10 +30,8 @@ func GetSubscriptionPlans(c *gin.Context) { } result := make([]SubscriptionPlanDTO, 0, len(plans)) for _, p := range plans { - items, _ := model.GetSubscriptionPlanItems(p.Id) result = append(result, SubscriptionPlanDTO{ - Plan: p, - Items: items, + Plan: p, }) } common.ApiSuccess(c, result) @@ -99,18 +95,15 @@ func AdminListSubscriptionPlans(c *gin.Context) { } result := make([]SubscriptionPlanDTO, 0, len(plans)) for _, p := range plans { - items, _ := model.GetSubscriptionPlanItems(p.Id) result = append(result, SubscriptionPlanDTO{ - Plan: p, - Items: items, + Plan: p, }) } common.ApiSuccess(c, result) } type AdminUpsertSubscriptionPlanRequest struct { - Plan model.SubscriptionPlan `json:"plan"` - Items []model.SubscriptionPlanItem `json:"items"` + Plan model.SubscriptionPlan `json:"plan"` } func AdminCreateSubscriptionPlan(c *gin.Context) { @@ -138,39 +131,16 @@ func AdminCreateSubscriptionPlan(c *gin.Context) { common.ApiErrorMsg(c, "购买上限不能为负数") return } + if req.Plan.TotalAmount < 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秒") return } - - if len(req.Items) == 0 { - common.ApiErrorMsg(c, "套餐至少需要配置一个模型权益") - return - } - - db := model.DB - err := db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(&req.Plan).Error; err != nil { - return err - } - items := make([]model.SubscriptionPlanItem, 0, len(req.Items)) - for _, it := range req.Items { - if strings.TrimSpace(it.ModelName) == "" { - continue - } - if it.AmountTotal <= 0 { - continue - } - it.Id = 0 - it.PlanId = req.Plan.Id - items = append(items, it) - } - if len(items) == 0 { - return errors.New("无有效的模型权益配置") - } - return tx.Create(&items).Error - }) + err := model.DB.Create(&req.Plan).Error if err != nil { common.ApiError(c, err) return @@ -209,17 +179,16 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { common.ApiErrorMsg(c, "购买上限不能为负数") return } + if req.Plan.TotalAmount < 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秒") return } - if len(req.Items) == 0 { - common.ApiErrorMsg(c, "套餐至少需要配置一个模型权益") - return - } - err := model.DB.Transaction(func(tx *gorm.DB) error { // update plan (allow zero values updates with map) updateMap := map[string]interface{}{ @@ -235,31 +204,13 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { "stripe_price_id": req.Plan.StripePriceId, "creem_product_id": req.Plan.CreemProductId, "max_purchase_per_user": req.Plan.MaxPurchasePerUser, + "total_amount": req.Plan.TotalAmount, "updated_at": common.GetTimestamp(), } if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil { return err } - // replace items - if err := tx.Where("plan_id = ?", id).Delete(&model.SubscriptionPlanItem{}).Error; err != nil { - return err - } - items := make([]model.SubscriptionPlanItem, 0, len(req.Items)) - for _, it := range req.Items { - if strings.TrimSpace(it.ModelName) == "" { - continue - } - if it.AmountTotal <= 0 { - continue - } - it.Id = 0 - it.PlanId = id - items = append(items, it) - } - if len(items) == 0 { - return errors.New("无有效的模型权益配置") - } - return tx.Create(&items).Error + return nil }) if err != nil { common.ApiError(c, err) diff --git a/model/main.go b/model/main.go index ab5c7d714..08c855309 100644 --- a/model/main.go +++ b/model/main.go @@ -269,10 +269,8 @@ func migrateDB() error { &TwoFABackupCode{}, &Checkin{}, &SubscriptionPlan{}, - &SubscriptionPlanItem{}, &SubscriptionOrder{}, &UserSubscription{}, - &UserSubscriptionItem{}, &SubscriptionPreConsumeRecord{}, ) if err != nil { @@ -309,10 +307,8 @@ func migrateDBFast() error { {&TwoFABackupCode{}, "TwoFABackupCode"}, {&Checkin{}, "Checkin"}, {&SubscriptionPlan{}, "SubscriptionPlan"}, - {&SubscriptionPlanItem{}, "SubscriptionPlanItem"}, {&SubscriptionOrder{}, "SubscriptionOrder"}, {&UserSubscription{}, "UserSubscription"}, - {&UserSubscriptionItem{}, "UserSubscriptionItem"}, {&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"}, } // 动态计算migration数量,确保errChan缓冲区足够大 diff --git a/model/subscription.go b/model/subscription.go index 336edb9d1..f42efeb2a 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -38,19 +38,16 @@ var ( ) const ( - subscriptionPlanCacheNamespace = "new-api:subscription_plan:v1" - subscriptionPlanItemsCacheNamespace = "new-api:subscription_plan_items:v1" - subscriptionPlanInfoCacheNamespace = "new-api:subscription_plan_info:v1" + subscriptionPlanCacheNamespace = "new-api:subscription_plan:v1" + subscriptionPlanInfoCacheNamespace = "new-api:subscription_plan_info:v1" ) var ( - subscriptionPlanCacheOnce sync.Once - subscriptionPlanItemsCacheOnce sync.Once - subscriptionPlanInfoCacheOnce sync.Once + subscriptionPlanCacheOnce sync.Once + subscriptionPlanInfoCacheOnce sync.Once - subscriptionPlanCache *cachex.HybridCache[SubscriptionPlan] - subscriptionPlanItemsCache *cachex.HybridCache[[]SubscriptionPlanItem] - subscriptionPlanInfoCache *cachex.HybridCache[SubscriptionPlanInfo] + subscriptionPlanCache *cachex.HybridCache[SubscriptionPlan] + subscriptionPlanInfoCache *cachex.HybridCache[SubscriptionPlanInfo] ) func subscriptionPlanCacheTTL() time.Duration { @@ -61,14 +58,6 @@ func subscriptionPlanCacheTTL() time.Duration { return time.Duration(ttlSeconds) * time.Second } -func subscriptionPlanItemsCacheTTL() time.Duration { - ttlSeconds := common.GetEnvOrDefault("SUBSCRIPTION_PLAN_ITEMS_CACHE_TTL", 300) - if ttlSeconds <= 0 { - ttlSeconds = 300 - } - return time.Duration(ttlSeconds) * time.Second -} - func subscriptionPlanInfoCacheTTL() time.Duration { ttlSeconds := common.GetEnvOrDefault("SUBSCRIPTION_PLAN_INFO_CACHE_TTL", 120) if ttlSeconds <= 0 { @@ -85,14 +74,6 @@ func subscriptionPlanCacheCapacity() int { return capacity } -func subscriptionPlanItemsCacheCapacity() int { - capacity := common.GetEnvOrDefault("SUBSCRIPTION_PLAN_ITEMS_CACHE_CAP", 10000) - if capacity <= 0 { - capacity = 10000 - } - return capacity -} - func subscriptionPlanInfoCacheCapacity() int { capacity := common.GetEnvOrDefault("SUBSCRIPTION_PLAN_INFO_CACHE_CAP", 10000) if capacity <= 0 { @@ -122,27 +103,6 @@ func getSubscriptionPlanCache() *cachex.HybridCache[SubscriptionPlan] { return subscriptionPlanCache } -func getSubscriptionPlanItemsCache() *cachex.HybridCache[[]SubscriptionPlanItem] { - subscriptionPlanItemsCacheOnce.Do(func() { - ttl := subscriptionPlanItemsCacheTTL() - subscriptionPlanItemsCache = cachex.NewHybridCache[[]SubscriptionPlanItem](cachex.HybridCacheConfig[[]SubscriptionPlanItem]{ - Namespace: cachex.Namespace(subscriptionPlanItemsCacheNamespace), - Redis: common.RDB, - RedisEnabled: func() bool { - return common.RedisEnabled && common.RDB != nil - }, - RedisCodec: cachex.JSONCodec[[]SubscriptionPlanItem]{}, - Memory: func() *hot.HotCache[string, []SubscriptionPlanItem] { - return hot.NewHotCache[string, []SubscriptionPlanItem](hot.LRU, subscriptionPlanItemsCacheCapacity()). - WithTTL(ttl). - WithJanitor(). - Build() - }, - }) - }) - return subscriptionPlanItemsCache -} - func getSubscriptionPlanInfoCache() *cachex.HybridCache[SubscriptionPlanInfo] { subscriptionPlanInfoCacheOnce.Do(func() { ttl := subscriptionPlanInfoCacheTTL() @@ -177,8 +137,6 @@ func InvalidateSubscriptionPlanCache(planId int) { } cache := getSubscriptionPlanCache() _, _ = cache.DeleteMany([]string{subscriptionPlanCacheKey(planId)}) - itemsCache := getSubscriptionPlanItemsCache() - _, _ = itemsCache.DeleteMany([]string{subscriptionPlanCacheKey(planId)}) infoCache := getSubscriptionPlanInfoCache() _ = infoCache.Purge() } @@ -207,7 +165,10 @@ type SubscriptionPlan struct { // Max purchases per user (0 = unlimited) MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"` - // Quota reset period for plan items + // Total quota (amount in quota units, 0 = unlimited) + TotalAmount int64 `json:"total_amount" gorm:"type:bigint;not null;default:0"` + + // Quota reset period for plan QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:'never'"` QuotaResetCustomSeconds int64 `json:"quota_reset_custom_seconds" gorm:"type:bigint;default:0"` @@ -227,18 +188,6 @@ func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error { return nil } -type SubscriptionPlanItem struct { - Id int `json:"id"` - PlanId int `json:"plan_id" gorm:"index"` - - ModelName string `json:"model_name" gorm:"type:varchar(128);index"` - // 0=按量(额度), 1=按次(次数) - QuotaType int `json:"quota_type" gorm:"type:int;index"` - - // If quota_type=0 => amount in quota units; if quota_type=1 => request count. - AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"` -} - // Subscription order (payment -> webhook -> create UserSubscription) type SubscriptionOrder struct { Id int `json:"id"` @@ -283,12 +232,18 @@ type UserSubscription struct { UserId int `json:"user_id" gorm:"index;index:idx_user_sub_active,priority:1"` PlanId int `json:"plan_id" gorm:"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"` + StartTime int64 `json:"start_time" gorm:"bigint"` EndTime int64 `json:"end_time" gorm:"bigint;index;index:idx_user_sub_active,priority:3"` Status string `json:"status" gorm:"type:varchar(32);index;index:idx_user_sub_active,priority:2"` // active/expired/cancelled Source string `json:"source" gorm:"type:varchar(32);default:'order'"` // order/admin + LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"` + NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` UpdatedAt int64 `json:"updated_at" gorm:"bigint"` } @@ -305,20 +260,8 @@ func (s *UserSubscription) BeforeUpdate(tx *gorm.DB) error { return nil } -type UserSubscriptionItem struct { - Id int `json:"id"` - UserSubscriptionId int `json:"user_subscription_id" gorm:"index;index:idx_sub_item_model_quota,priority:3"` - ModelName string `json:"model_name" gorm:"type:varchar(128);index;index:idx_sub_item_model_quota,priority:1"` - QuotaType int `json:"quota_type" gorm:"type:int;index;index:idx_sub_item_model_quota,priority:2"` - 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 { - Subscription *UserSubscription `json:"subscription"` - Items []UserSubscriptionItem `json:"items"` + Subscription *UserSubscription `json:"subscription"` } func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) { @@ -423,24 +366,6 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) { return &plan, nil } -func GetSubscriptionPlanItems(planId int) ([]SubscriptionPlanItem, error) { - if planId <= 0 { - return nil, errors.New("invalid plan id") - } - key := subscriptionPlanCacheKey(planId) - if key != "" { - if cached, found, err := getSubscriptionPlanItemsCache().Get(key); err == nil && found { - return cached, nil - } - } - var items []SubscriptionPlanItem - if err := DB.Where("plan_id = ?", planId).Find(&items).Error; err != nil { - return nil, err - } - _ = getSubscriptionPlanItemsCache().SetWithTTL(key, items, subscriptionPlanItemsCacheTTL()) - return items, nil -} - func CountUserSubscriptionsByPlan(userId int, planId int) (int64, error) { if userId <= 0 || planId <= 0 { return 0, errors.New("invalid userId or planId") @@ -488,40 +413,22 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio lastReset = now.Unix() } sub := &UserSubscription{ - UserId: userId, - PlanId: plan.Id, - StartTime: now.Unix(), - EndTime: endUnix, - Status: "active", - Source: source, - CreatedAt: common.GetTimestamp(), - UpdatedAt: common.GetTimestamp(), + UserId: userId, + PlanId: plan.Id, + AmountTotal: plan.TotalAmount, + AmountUsed: 0, + StartTime: now.Unix(), + EndTime: endUnix, + Status: "active", + Source: source, + LastResetTime: lastReset, + NextResetTime: nextReset, + CreatedAt: common.GetTimestamp(), + UpdatedAt: common.GetTimestamp(), } if err := tx.Create(sub).Error; err != nil { return nil, err } - items, err := GetSubscriptionPlanItems(plan.Id) - if err != nil { - return nil, err - } - if len(items) == 0 { - return nil, errors.New("plan has no items") - } - userItems := make([]UserSubscriptionItem, 0, len(items)) - for _, it := range items { - userItems = append(userItems, UserSubscriptionItem{ - UserSubscriptionId: sub.Id, - ModelName: it.ModelName, - QuotaType: it.QuotaType, - AmountTotal: it.AmountTotal, - AmountUsed: 0, - LastResetTime: lastReset, - NextResetTime: nextReset, - }) - } - if err := tx.Create(&userItems).Error; err != nil { - return nil, err - } return sub, nil } @@ -671,7 +578,7 @@ func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) { if err != nil { return nil, err } - return buildSubscriptionSummaries(subs) + return buildSubscriptionSummaries(subs), nil } // GetAllUserSubscriptions returns all subscriptions (active and expired) for a user. @@ -686,34 +593,21 @@ func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) { if err != nil { return nil, err } - return buildSubscriptionSummaries(subs) + return buildSubscriptionSummaries(subs), nil } -func buildSubscriptionSummaries(subs []UserSubscription) ([]SubscriptionSummary, error) { +func buildSubscriptionSummaries(subs []UserSubscription) []SubscriptionSummary { if len(subs) == 0 { - return []SubscriptionSummary{}, nil - } - subIds := make([]int, 0, len(subs)) - for _, sub := range subs { - subIds = append(subIds, sub.Id) - } - var items []UserSubscriptionItem - if err := DB.Where("user_subscription_id IN ?", subIds).Find(&items).Error; err != nil { - return nil, err - } - itemsMap := make(map[int][]UserSubscriptionItem, len(subIds)) - for _, it := range items { - itemsMap[it.UserSubscriptionId] = append(itemsMap[it.UserSubscriptionId], it) + return []SubscriptionSummary{} } result := make([]SubscriptionSummary, 0, len(subs)) for _, sub := range subs { subCopy := sub result = append(result, SubscriptionSummary{ Subscription: &subCopy, - Items: itemsMap[sub.Id], }) } - return result, nil + return result } // AdminInvalidateUserSubscription marks a user subscription as cancelled and ends it immediately. @@ -731,26 +625,16 @@ func AdminInvalidateUserSubscription(userSubscriptionId int) error { }).Error } -// AdminDeleteUserSubscription hard-deletes a user subscription and its items. +// AdminDeleteUserSubscription hard-deletes a user subscription. func AdminDeleteUserSubscription(userSubscriptionId int) error { if userSubscriptionId <= 0 { return errors.New("invalid userSubscriptionId") } - return DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("user_subscription_id = ?", userSubscriptionId).Delete(&UserSubscriptionItem{}).Error; err != nil { - return err - } - if err := tx.Where("id = ?", userSubscriptionId).Delete(&UserSubscription{}).Error; err != nil { - return err - } - return nil - }) + return DB.Where("id = ?", userSubscriptionId).Delete(&UserSubscription{}).Error } type SubscriptionPreConsumeResult struct { UserSubscriptionId int - ItemId int - QuotaType int PreConsumed int64 AmountTotal int64 AmountUsedBefore int64 @@ -759,14 +643,14 @@ type SubscriptionPreConsumeResult struct { // SubscriptionPreConsumeRecord stores idempotent pre-consume operations per request. type SubscriptionPreConsumeRecord struct { - Id int `json:"id"` - RequestId string `json:"request_id" gorm:"type:varchar(64);uniqueIndex"` - UserId int `json:"user_id" gorm:"index"` - UserSubscriptionItemId int `json:"user_subscription_item_id" gorm:"index"` - PreConsumed int64 `json:"pre_consumed" gorm:"type:bigint;not null;default:0"` - Status string `json:"status" gorm:"type:varchar(32);index"` // consumed/refunded - CreatedAt int64 `json:"created_at" gorm:"bigint"` - UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"` + Id int `json:"id"` + RequestId string `json:"request_id" gorm:"type:varchar(64);uniqueIndex"` + UserId int `json:"user_id" gorm:"index"` + UserSubscriptionId int `json:"user_subscription_id" gorm:"index"` + PreConsumed int64 `json:"pre_consumed" gorm:"type:bigint;not null;default:0"` + Status string `json:"status" gorm:"type:varchar(32);index"` // consumed/refunded + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"` } func (r *SubscriptionPreConsumeRecord) BeforeCreate(tx *gorm.DB) error { @@ -781,36 +665,17 @@ func (r *SubscriptionPreConsumeRecord) BeforeUpdate(tx *gorm.DB) error { return nil } -func maybeResetSubscriptionItemTx(tx *gorm.DB, item *UserSubscriptionItem, now int64) error { - if tx == nil || item == nil { +func maybeResetUserSubscriptionWithPlanTx(tx *gorm.DB, sub *UserSubscription, plan *SubscriptionPlan, now int64) error { + if tx == nil || sub == nil || plan == 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 - } - plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId) - if err != nil { - return err - } - return maybeResetSubscriptionItemWithPlanTx(tx, item, &sub, plan, now) -} - -func maybeResetSubscriptionItemWithPlanTx(tx *gorm.DB, item *UserSubscriptionItem, sub *UserSubscription, plan *SubscriptionPlan, now int64) error { - if tx == nil || item == nil || sub == nil || plan == nil { - return errors.New("invalid reset args") - } - if item.NextResetTime > 0 && item.NextResetTime > now { + if sub.NextResetTime > 0 && sub.NextResetTime > now { return nil } if NormalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever { return nil } - - baseUnix := item.LastResetTime + baseUnix := sub.LastResetTime if baseUnix <= 0 { baseUnix = sub.StartTime } @@ -823,22 +688,20 @@ func maybeResetSubscriptionItemWithPlanTx(tx *gorm.DB, item *UserSubscriptionIte 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 + if sub.NextResetTime == 0 && next > 0 { + sub.NextResetTime = next + sub.LastResetTime = base.Unix() + return tx.Save(sub).Error } return nil } - item.AmountUsed = 0 - item.LastResetTime = base.Unix() - item.NextResetTime = next - return tx.Save(item).Error + sub.AmountUsed = 0 + sub.LastResetTime = base.Unix() + sub.NextResetTime = next + return tx.Save(sub).Error } -// PreConsumeUserSubscription finds a valid active subscription item and increments amount_used. -// quotaType=0 => consume quota units; quotaType=1 => consume request count (usually 1). +// PreConsumeUserSubscription pre-consumes from any active subscription total quota. func PreConsumeUserSubscription(requestId string, userId int, modelName string, quotaType int, amount int64) (*SubscriptionPreConsumeResult, error) { if userId <= 0 { return nil, errors.New("invalid userId") @@ -846,9 +709,6 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string, if strings.TrimSpace(requestId) == "" { return nil, errors.New("requestId is empty") } - if modelName == "" { - return nil, errors.New("modelName is empty") - } if amount <= 0 { return nil, errors.New("amount must be > 0") } @@ -866,92 +726,78 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string, if existing.Status == "refunded" { return errors.New("subscription pre-consume already refunded") } - var item UserSubscriptionItem - if err := tx.Where("id = ?", existing.UserSubscriptionItemId).First(&item).Error; err != nil { + var sub UserSubscription + if err := tx.Where("id = ?", existing.UserSubscriptionId).First(&sub).Error; err != nil { return err } - returnValue.UserSubscriptionId = item.UserSubscriptionId - returnValue.ItemId = item.Id - returnValue.QuotaType = item.QuotaType + returnValue.UserSubscriptionId = sub.Id returnValue.PreConsumed = existing.PreConsumed - returnValue.AmountTotal = item.AmountTotal - returnValue.AmountUsedBefore = item.AmountUsed - returnValue.AmountUsedAfter = item.AmountUsed + returnValue.AmountTotal = sub.AmountTotal + returnValue.AmountUsedBefore = sub.AmountUsed + returnValue.AmountUsedAfter = sub.AmountUsed return nil } - var activeSub UserSubscription - if err := tx.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now). - Order("end_time desc, id desc"). - First(&activeSub).Error; err != nil { - return errors.New("no active subscription item for this model") - } - var candidate UserSubscriptionItem - if err := tx.Where("user_subscription_id = ? AND model_name = ? AND quota_type = ?", activeSub.Id, modelName, quotaType). - Order("id desc"). - First(&candidate).Error; err != nil { - return errors.New("no active subscription item for this model") - } - var item UserSubscriptionItem + var subs []UserSubscription if err := tx.Set("gorm:query_option", "FOR UPDATE"). - Where("id = ?", candidate.Id). - First(&item).Error; err != nil { - return errors.New("no active subscription item for this model") + Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now). + Order("end_time asc, id asc"). + Find(&subs).Error; err != nil { + return errors.New("no active subscription") } - - var sub UserSubscription - if err := tx.Where("id = ? AND user_id = ? AND status = ? AND end_time > ?", item.UserSubscriptionId, userId, "active", now). - First(&sub).Error; err != nil { - return errors.New("no active subscription item for this model") + if len(subs) == 0 { + return errors.New("no active subscription") } - plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId) - if err != nil { - return err - } - if err := maybeResetSubscriptionItemWithPlanTx(tx, &item, &sub, plan, now); err != nil { - return err - } - usedBefore := item.AmountUsed - remain := item.AmountTotal - usedBefore - if remain < amount { - return fmt.Errorf("subscription quota insufficient, remain=%d need=%d", remain, amount) - } - record := &SubscriptionPreConsumeRecord{ - RequestId: requestId, - UserId: userId, - UserSubscriptionItemId: item.Id, - PreConsumed: amount, - Status: "consumed", - } - if err := tx.Create(record).Error; err != nil { - var dup SubscriptionPreConsumeRecord - if err2 := tx.Where("request_id = ?", requestId).First(&dup).Error; err2 == nil { - if dup.Status == "refunded" { - return errors.New("subscription pre-consume already refunded") - } - returnValue.UserSubscriptionId = item.UserSubscriptionId - returnValue.ItemId = item.Id - returnValue.QuotaType = item.QuotaType - returnValue.PreConsumed = dup.PreConsumed - returnValue.AmountTotal = item.AmountTotal - returnValue.AmountUsedBefore = item.AmountUsed - returnValue.AmountUsedAfter = item.AmountUsed - return nil + for _, candidate := range subs { + sub := candidate + plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId) + if err != nil { + return err } - return err + if err := maybeResetUserSubscriptionWithPlanTx(tx, &sub, plan, now); err != nil { + return err + } + usedBefore := sub.AmountUsed + if sub.AmountTotal > 0 { + remain := sub.AmountTotal - usedBefore + if remain < amount { + continue + } + } + record := &SubscriptionPreConsumeRecord{ + RequestId: requestId, + UserId: userId, + UserSubscriptionId: sub.Id, + PreConsumed: amount, + Status: "consumed", + } + if err := tx.Create(record).Error; err != nil { + var dup SubscriptionPreConsumeRecord + if err2 := tx.Where("request_id = ?", requestId).First(&dup).Error; err2 == nil { + if dup.Status == "refunded" { + return errors.New("subscription pre-consume already refunded") + } + returnValue.UserSubscriptionId = sub.Id + returnValue.PreConsumed = dup.PreConsumed + returnValue.AmountTotal = sub.AmountTotal + returnValue.AmountUsedBefore = sub.AmountUsed + returnValue.AmountUsedAfter = sub.AmountUsed + return nil + } + return err + } + sub.AmountUsed += amount + if err := tx.Save(&sub).Error; err != nil { + return err + } + returnValue.UserSubscriptionId = sub.Id + returnValue.PreConsumed = amount + returnValue.AmountTotal = sub.AmountTotal + returnValue.AmountUsedBefore = usedBefore + returnValue.AmountUsedAfter = sub.AmountUsed + return nil } - item.AmountUsed += amount - if err := tx.Save(&item).Error; err != nil { - return err - } - returnValue.UserSubscriptionId = item.UserSubscriptionId - returnValue.ItemId = item.Id - returnValue.QuotaType = item.QuotaType - returnValue.PreConsumed = amount - returnValue.AmountTotal = item.AmountTotal - returnValue.AmountUsedBefore = usedBefore - returnValue.AmountUsedAfter = item.AmountUsed - return nil + return fmt.Errorf("subscription quota insufficient, need=%d", amount) }) if err != nil { return nil, err @@ -977,7 +823,7 @@ func RefundSubscriptionPreConsume(requestId string) error { record.Status = "refunded" return tx.Save(&record).Error } - if err := PostConsumeUserSubscriptionDelta(record.UserSubscriptionItemId, -record.PreConsumed); err != nil { + if err := PostConsumeUserSubscriptionDelta(record.UserSubscriptionId, -record.PreConsumed); err != nil { return err } record.Status = "refunded" @@ -985,77 +831,37 @@ func RefundSubscriptionPreConsume(requestId string) error { }) } -// ResetDueSubscriptionItems resets items whose next_reset_time has passed. -func ResetDueSubscriptionItems(limit int) (int, error) { +// ResetDueSubscriptions resets subscriptions whose next_reset_time has passed. +func ResetDueSubscriptions(limit int) (int, error) { if limit <= 0 { limit = 200 } now := GetDBTimestamp() - var items []UserSubscriptionItem - if err := DB.Where("next_reset_time > 0 AND next_reset_time <= ?", now). + var subs []UserSubscription + if err := DB.Where("next_reset_time > 0 AND next_reset_time <= ? AND status = ?", now, "active"). Order("next_reset_time asc"). Limit(limit). - Find(&items).Error; err != nil { + Find(&subs).Error; err != nil { return 0, err } - if len(items) == 0 { + if len(subs) == 0 { return 0, nil } - subIds := make([]int, 0, len(items)) - subIdSet := make(map[int]struct{}, len(items)) - for _, it := range items { - if it.UserSubscriptionId <= 0 { - continue - } - if _, exists := subIdSet[it.UserSubscriptionId]; exists { - continue - } - subIdSet[it.UserSubscriptionId] = struct{}{} - subIds = append(subIds, it.UserSubscriptionId) - } - subById := make(map[int]*UserSubscription, len(subIds)) - if len(subIds) > 0 { - var subs []UserSubscription - if err := DB.Where("id IN ?", subIds).Find(&subs).Error; err != nil { - return 0, err - } - for i := range subs { - sub := subs[i] - subById[sub.Id] = &sub - } - } - planById := make(map[int]*SubscriptionPlan, len(subById)) - for _, sub := range subById { - if sub == nil || sub.PlanId <= 0 { - continue - } - if _, exists := planById[sub.PlanId]; exists { - continue - } - plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId) - if err != nil { - return 0, err - } - planById[sub.PlanId] = plan - } resetCount := 0 - for _, it := range items { - sub := subById[it.UserSubscriptionId] - if sub == nil { + for _, sub := range subs { + subCopy := sub + plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId) + if err != nil || plan == nil { continue } - plan := planById[sub.PlanId] - if plan == nil { - continue - } - err := DB.Transaction(func(tx *gorm.DB) error { - var item UserSubscriptionItem + err = DB.Transaction(func(tx *gorm.DB) error { + var locked UserSubscription if err := tx.Set("gorm:query_option", "FOR UPDATE"). - Where("id = ? AND next_reset_time > 0 AND next_reset_time <= ?", it.Id, now). - First(&item).Error; err != nil { + Where("id = ? AND next_reset_time > 0 AND next_reset_time <= ?", subCopy.Id, now). + First(&locked).Error; err != nil { return nil } - if err := maybeResetSubscriptionItemWithPlanTx(tx, &item, sub, plan, now); err != nil { + if err := maybeResetUserSubscriptionWithPlanTx(tx, &locked, plan, now); err != nil { return err } resetCount++ @@ -1107,38 +913,29 @@ func GetSubscriptionPlanInfoByUserSubscriptionId(userSubscriptionId int) (*Subsc return info, nil } -func GetSubscriptionPlanInfoBySubscriptionItemId(itemId int) (*SubscriptionPlanInfo, error) { - if itemId <= 0 { - return nil, errors.New("invalid itemId") - } - var item UserSubscriptionItem - if err := DB.Where("id = ?", itemId).First(&item).Error; err != nil { - return nil, err - } - return GetSubscriptionPlanInfoByUserSubscriptionId(item.UserSubscriptionId) -} - // Update subscription used amount by delta (positive consume more, negative refund). -func PostConsumeUserSubscriptionDelta(itemId int, delta int64) error { - if itemId <= 0 { - return errors.New("invalid itemId") +func PostConsumeUserSubscriptionDelta(userSubscriptionId int, delta int64) error { + if userSubscriptionId <= 0 { + return errors.New("invalid userSubscriptionId") } if delta == 0 { return nil } return DB.Transaction(func(tx *gorm.DB) error { - var item UserSubscriptionItem - if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", itemId).First(&item).Error; err != nil { + var sub UserSubscription + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", userSubscriptionId). + First(&sub).Error; err != nil { return err } - newUsed := item.AmountUsed + delta + newUsed := sub.AmountUsed + delta if newUsed < 0 { newUsed = 0 } - if newUsed > item.AmountTotal { - return fmt.Errorf("subscription used exceeds total, used=%d total=%d", newUsed, item.AmountTotal) + if sub.AmountTotal > 0 && newUsed > sub.AmountTotal { + return fmt.Errorf("subscription used exceeds total, used=%d total=%d", newUsed, sub.AmountTotal) } - item.AmountUsed = newUsed - return tx.Save(&item).Error + sub.AmountUsed = newUsed + return tx.Save(&sub).Error }) } diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 232e8c852..20ca53ed1 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -117,14 +117,11 @@ type RelayInfo struct { // BillingSource indicates whether this request is billed from wallet quota or subscription. // "" or "wallet" => wallet; "subscription" => subscription BillingSource string - // SubscriptionItemId is the user_subscription_items.id used when BillingSource == "subscription" - SubscriptionItemId int - // SubscriptionQuotaType is the plan item quota type: 0=quota units, 1=request count - SubscriptionQuotaType int + // SubscriptionId is the user_subscriptions.id used when BillingSource == "subscription" + SubscriptionId int // SubscriptionPreConsumed is the amount pre-consumed on subscription item (quota units or 1) SubscriptionPreConsumed int64 // SubscriptionPostDelta is the post-consume delta applied to amount_used (quota units; can be negative). - // Only meaningful when SubscriptionQuotaType == 0. SubscriptionPostDelta int64 // SubscriptionPlanId / SubscriptionPlanTitle are used for logging/UI display. SubscriptionPlanId int diff --git a/service/billing.go b/service/billing.go index 3b80feed8..17306c191 100644 --- a/service/billing.go +++ b/service/billing.go @@ -26,17 +26,9 @@ func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycom pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference) trySubscription := func() *types.NewAPIError { - quotaTypes := model.GetModelQuotaTypes(relayInfo.OriginModelName) quotaType := 0 - if len(quotaTypes) > 0 { - quotaType = quotaTypes[0] - } - - // For subscription item: per-request consumes 1, per-quota consumes preConsumedQuota quota units. + // For total quota: consume preConsumedQuota quota units. subConsume := int64(preConsumedQuota) - if quotaType == 1 { - subConsume = 1 - } if subConsume <= 0 { subConsume = 1 } @@ -58,8 +50,7 @@ func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycom } relayInfo.BillingSource = BillingSourceSubscription - relayInfo.SubscriptionItemId = res.ItemId - relayInfo.SubscriptionQuotaType = quotaType + relayInfo.SubscriptionId = res.UserSubscriptionId relayInfo.SubscriptionPreConsumed = res.PreConsumed relayInfo.SubscriptionPostDelta = 0 relayInfo.SubscriptionAmountTotal = res.AmountTotal @@ -76,8 +67,7 @@ func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycom tryWallet := func() *types.NewAPIError { relayInfo.BillingSource = BillingSourceWallet - relayInfo.SubscriptionItemId = 0 - relayInfo.SubscriptionQuotaType = 0 + relayInfo.SubscriptionId = 0 relayInfo.SubscriptionPreConsumed = 0 return PreConsumeQuota(c, preConsumedQuota, relayInfo) } diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 1bba8ea55..771da5b77 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -6,7 +6,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" - "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/types" @@ -90,15 +89,14 @@ func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interf other["billing_preference"] = relayInfo.UserSetting.BillingPreference } if relayInfo.BillingSource == "subscription" { - if relayInfo.SubscriptionItemId != 0 { - other["subscription_item_id"] = relayInfo.SubscriptionItemId + if relayInfo.SubscriptionId != 0 { + other["subscription_id"] = relayInfo.SubscriptionId } - other["subscription_quota_type"] = relayInfo.SubscriptionQuotaType if relayInfo.SubscriptionPreConsumed > 0 { other["subscription_pre_consumed"] = relayInfo.SubscriptionPreConsumed } // post_delta: settlement delta applied after actual usage is known (can be negative for refund) - if relayInfo.SubscriptionQuotaType == 0 && relayInfo.SubscriptionPostDelta != 0 { + if relayInfo.SubscriptionPostDelta != 0 { other["subscription_post_delta"] = relayInfo.SubscriptionPostDelta } if relayInfo.SubscriptionPlanId != 0 { @@ -108,12 +106,8 @@ func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interf other["subscription_plan_title"] = relayInfo.SubscriptionPlanTitle } // Compute "this request" subscription consumed + remaining - consumed := relayInfo.SubscriptionPreConsumed - usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume - if relayInfo.SubscriptionQuotaType == 0 { - consumed = relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta - usedFinal = relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta - } + consumed := relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta + usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta if consumed < 0 { consumed = 0 } @@ -132,13 +126,6 @@ func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interf if consumed > 0 { other["subscription_consumed"] = consumed } - // Fallback: if plan info missing (older requests), best-effort fetch by item id. - if relayInfo.SubscriptionPlanId == 0 && relayInfo.SubscriptionItemId != 0 { - if info, err := model.GetSubscriptionPlanInfoBySubscriptionItemId(relayInfo.SubscriptionItemId); err == nil && info != nil { - other["subscription_plan_id"] = info.PlanId - other["subscription_plan_title"] = info.PlanTitle - } - } // Wallet quota is not deducted when billed from subscription. other["wallet_quota_deducted"] = 0 } diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go index 62b19bb75..5d5b7bb20 100644 --- a/service/pre_consume_quota.go +++ b/service/pre_consume_quota.go @@ -17,7 +17,7 @@ import ( func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) { // Always refund subscription pre-consumed (can be non-zero even when FinalPreConsumedQuota is 0) - needRefundSub := relayInfo.BillingSource == BillingSourceSubscription && relayInfo.SubscriptionItemId != 0 && relayInfo.SubscriptionPreConsumed > 0 + needRefundSub := relayInfo.BillingSource == BillingSourceSubscription && relayInfo.SubscriptionId != 0 && relayInfo.SubscriptionPreConsumed > 0 needRefundToken := relayInfo.FinalPreConsumedQuota != 0 if !needRefundSub && !needRefundToken { return diff --git a/service/quota.go b/service/quota.go index a2178fec0..e012e345a 100644 --- a/service/quota.go +++ b/service/quota.go @@ -505,16 +505,15 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu // 1) Consume from wallet quota OR subscription item if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription { - // For subscription: quotaType=0 uses quota units delta; quotaType=1 uses fixed 0 delta (pre-consumed 1 on request begin) - if relayInfo.SubscriptionQuotaType == 0 { - if relayInfo.SubscriptionItemId == 0 { - return errors.New("subscription item id is missing") - } - if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionItemId, int64(quota)); err != nil { + if relayInfo.SubscriptionId == 0 { + return errors.New("subscription id is missing") + } + delta := int64(quota) - relayInfo.SubscriptionPreConsumed + if delta != 0 { + if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil { return err } - // Track delta for logging/UI (net consumed = preConsumed + postDelta) - relayInfo.SubscriptionPostDelta += int64(quota) + relayInfo.SubscriptionPostDelta += delta } } else { // Wallet diff --git a/service/subscription_reset_task.go b/service/subscription_reset_task.go index 630d91ef9..453c5e8d6 100644 --- a/service/subscription_reset_task.go +++ b/service/subscription_reset_task.go @@ -53,7 +53,7 @@ func runSubscriptionQuotaResetOnce() { ctx := context.Background() totalReset := 0 for { - n, err := model.ResetDueSubscriptionItems(subscriptionResetBatchSize) + n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize) if err != nil { logger.LogWarn(ctx, fmt.Sprintf("subscription quota reset task failed: %v", err)) return diff --git a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx index 103ef040b..58ca2817e 100644 --- a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx +++ b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx @@ -33,8 +33,6 @@ import { convertUSDToCurrency } from '../../../helpers/render'; const { Text } = Typography; -const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量'); - function formatDuration(plan, t) { if (!plan) return ''; const u = plan.duration_unit || 'month'; @@ -68,8 +66,6 @@ function formatResetPeriod(plan, t) { const renderPlanTitle = (text, record, t) => { const subtitle = record?.plan?.subtitle; const plan = record?.plan; - const items = record?.items || []; - const popoverContent = (
{text} @@ -84,6 +80,8 @@ const renderPlanTitle = (text, record, t) => { {convertUSDToCurrency(Number(plan?.price_amount || 0), 2)} + {t('总额度')} + {plan?.total_amount > 0 ? plan.total_amount : t('不限')} {t('购买上限')} {plan?.max_purchase_per_user > 0 @@ -94,10 +92,6 @@ const renderPlanTitle = (text, record, t) => { {formatDuration(plan, t)} {t('重置')} {formatResetPeriod(plan, t)} - {t('模型')} - - {items.length} {t('个')} -
); @@ -165,54 +159,12 @@ const renderEnabled = (text, record, t) => { ); }; -const renderModels = (text, record, t) => { - const items = record?.items || []; - if (items.length === 0) { - return ; - } - - const popoverContent = ( -
- - {t('模型权益')} ({items.length}) - - - - {items.map((it, idx) => ( -
- - {it.model_name} - - - - {quotaTypeLabel(it.quota_type)} - - {it.amount_total} - -
- ))} -
-
- ); - +const renderTotalAmount = (text, record, t) => { + const total = Number(record?.plan?.total_amount || 0); return ( - - - {items.length} {t('个模型')} - - + 0 ? 'secondary' : 'tertiary'}> + {total > 0 ? total : t('不限')} + ); }; @@ -359,9 +311,9 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => { render: (text, record) => renderPaymentConfig(text, record, t), }, { - title: t('模型'), + title: t('总额度'), width: 100, - render: (text, record) => renderModels(text, record, t), + render: (text, record) => renderTotalAmount(text, record, t), }, { title: t('操作'), diff --git a/web/src/components/table/subscriptions/index.jsx b/web/src/components/table/subscriptions/index.jsx index cc4afe12b..52595a60b 100644 --- a/web/src/components/table/subscriptions/index.jsx +++ b/web/src/components/table/subscriptions/index.jsx @@ -41,7 +41,6 @@ const SubscriptionsPage = () => { openCreate, compactMode, setCompactMode, - pricingModels, t, } = subscriptionsData; @@ -52,7 +51,6 @@ const SubscriptionsPage = () => { handleClose={closeEdit} editingPlan={editingPlan} placement={sheetPlacement} - pricingModels={pricingModels} refresh={refresh} t={t} /> diff --git a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx index 7465cf5df..6309359d7 100644 --- a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx +++ b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx @@ -17,22 +17,18 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useMemo, useState, useRef } from 'react'; +import React, { useState, useRef } from 'react'; import { Avatar, Button, Card, Col, Form, - Input, - InputNumber, Row, Select, SideSheet, Space, Spin, - Switch, - Table, Tag, Typography, } from '@douyinfe/semi-ui'; @@ -40,11 +36,15 @@ import { IconCalendarClock, IconClose, IconCreditCard, - IconPlusCircle, IconSave, } from '@douyinfe/semi-icons'; -import { Trash2, Clock, Boxes, RefreshCw } from 'lucide-react'; -import { API, showError, showSuccess } from '../../../../helpers'; +import { Clock, RefreshCw } from 'lucide-react'; +import { + API, + showError, + showSuccess, + renderQuotaWithPrompt, +} from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; const { Text, Title } = Typography; @@ -65,14 +65,11 @@ const resetPeriodOptions = [ { value: 'custom', label: '自定义(秒)' }, ]; -const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量'); - const AddEditSubscriptionModal = ({ visible, handleClose, editingPlan, placement = 'left', - pricingModels = [], refresh, t, }) => { @@ -95,17 +92,11 @@ const AddEditSubscriptionModal = ({ enabled: true, sort_order: 0, max_purchase_per_user: 0, + total_amount: 0, stripe_price_id: '', creem_product_id: '', }); - const [items, setItems] = useState([]); - // Model benefits UX - const [pendingModels, setPendingModels] = useState([]); - const [defaultNewAmountTotal, setDefaultNewAmountTotal] = useState(0); - const [bulkAmountTotal, setBulkAmountTotal] = useState(0); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const buildFormValues = () => { const base = getInitValues(); if (editingPlan?.plan?.id === undefined) return base; @@ -124,152 +115,17 @@ const AddEditSubscriptionModal = ({ enabled: p.enabled !== false, sort_order: Number(p.sort_order || 0), max_purchase_per_user: Number(p.max_purchase_per_user || 0), + total_amount: Number(p.total_amount || 0), stripe_price_id: p.stripe_price_id || '', creem_product_id: p.creem_product_id || '', }; }; - useEffect(() => { - // 1) always keep items in sync - if (visible && isEdit && editingPlan) { - setItems((editingPlan.items || []).map((it) => ({ ...it }))); - } else if (visible && !isEdit) { - setItems([]); - } - }, [visible, editingPlan]); - - const modelOptions = useMemo(() => { - return (pricingModels || []).map((m) => ({ - label: `${m.model_name} (${quotaTypeLabel(m.quota_type)})`, - value: m.model_name, - quota_type: m.quota_type, - })); - }, [pricingModels]); - - const addItem = (modelName) => { - const modelMeta = modelOptions.find((m) => m.value === modelName); - if (!modelMeta) return; - if (items.some((it) => it.model_name === modelName)) { - showError(t('该模型已添加')); - return; - } - setItems([ - ...items, - { - model_name: modelName, - quota_type: modelMeta.quota_type, - amount_total: 0, - }, - ]); - }; - - const addPendingModels = () => { - const selected = (pendingModels || []).filter(Boolean); - if (selected.length === 0) { - showError(t('请选择要添加的模型')); - return; - } - const existing = new Set((items || []).map((it) => it.model_name)); - const toAdd = selected.filter((name) => !existing.has(name)); - if (toAdd.length === 0) { - showError(t('所选模型已全部存在')); - return; - } - const defaultAmount = Number(defaultNewAmountTotal || 0); - const next = [...items]; - toAdd.forEach((modelName) => { - const modelMeta = modelOptions.find((m) => m.value === modelName); - if (!modelMeta) return; - next.push({ - model_name: modelName, - quota_type: modelMeta.quota_type, - amount_total: - Number.isFinite(defaultAmount) && defaultAmount >= 0 - ? defaultAmount - : 0, - }); - }); - setItems(next); - setPendingModels([]); - showSuccess(t('已添加')); - }; - - const applyBulkAmountTotal = ({ scope }) => { - const n = Number(bulkAmountTotal || 0); - if (!Number.isFinite(n) || n < 0) { - showError(t('请输入有效的数量')); - return; - } - if (!items || items.length === 0) { - showError(t('请先添加模型权益')); - return; - } - - if (scope === 'selected') { - if (!selectedRowKeys || selectedRowKeys.length === 0) { - showError(t('请先勾选要批量设置的权益')); - return; - } - const keySet = new Set(selectedRowKeys); - setItems( - items.map((it) => { - const k = `${it.model_name}-${it.quota_type}`; - if (!keySet.has(k)) return it; - return { ...it, amount_total: n }; - }), - ); - showSuccess(t('已对选中项批量设置')); - return; - } - - // scope === 'all' - setItems(items.map((it) => ({ ...it, amount_total: n }))); - showSuccess(t('已对全部批量设置')); - }; - - const deleteSelectedItems = () => { - if (!selectedRowKeys || selectedRowKeys.length === 0) { - showError(t('请先勾选要删除的权益')); - return; - } - const keySet = new Set(selectedRowKeys); - const next = (items || []).filter( - (it) => !keySet.has(`${it.model_name}-${it.quota_type}`), - ); - setItems(next); - setSelectedRowKeys([]); - showSuccess(t('已删除选中项')); - }; - - const updateItem = (idx, patch) => { - const next = [...items]; - next[idx] = { ...next[idx], ...patch }; - setItems(next); - }; - - const removeItem = (idx) => { - const next = [...items]; - next.splice(idx, 1); - setItems(next); - }; - const submit = async (values) => { if (!values.title || values.title.trim() === '') { showError(t('套餐标题不能为空')); return; } - const cleanedItems = items - .filter((it) => it.model_name && Number(it.amount_total) > 0) - .map((it) => ({ - model_name: it.model_name, - quota_type: Number(it.quota_type || 0), - amount_total: Number(it.amount_total), - })); - if (cleanedItems.length === 0) { - showError(t('请至少配置一个模型权益(且数量>0)')); - return; - } - setLoading(true); try { const payload = { @@ -286,8 +142,8 @@ const AddEditSubscriptionModal = ({ : 0, sort_order: Number(values.sort_order || 0), max_purchase_per_user: Number(values.max_purchase_per_user || 0), + total_amount: Number(values.total_amount || 0), }, - items: cleanedItems, }; if (editingPlan?.plan?.id) { const res = await API.put( @@ -318,48 +174,6 @@ const AddEditSubscriptionModal = ({ } }; - const itemColumns = [ - { - title: t('模型'), - dataIndex: 'model_name', - render: (v, row) => ( -
-
{v}
-
- {t('计费')}: {quotaTypeLabel(row.quota_type)} -
-
- ), - }, - { - title: t('数量'), - dataIndex: 'amount_total', - width: 220, - render: (v, row, idx) => ( - updateItem(idx, { amount_total: val })} - placeholder={row.quota_type === 1 ? t('次数') : t('额度')} - style={{ width: '100%' }} - /> - ), - }, - { - title: '', - width: 60, - render: (_, __, idx) => ( - - - -
-
- - {t('已选')} {selectedRowKeys?.length || 0} - - setBulkAmountTotal(v)} - style={{ width: isMobile ? '100%' : 220 }} - placeholder={t('统一设置数量')} - /> -
-
- - - -
-
- - - setSelectedRowKeys(keys || []), - }} - rowKey={(row) => `${row.model_name}-${row.quota_type}`} - empty={ -
- {t('尚未添加任何模型')} -
- } - /> - )} diff --git a/web/src/components/table/users/modals/UserSubscriptionsModal.jsx b/web/src/components/table/users/modals/UserSubscriptionsModal.jsx index 9b9f89976..1c2b64030 100644 --- a/web/src/components/table/users/modals/UserSubscriptionsModal.jsx +++ b/web/src/components/table/users/modals/UserSubscriptionsModal.jsx @@ -22,7 +22,6 @@ import { Button, Empty, Modal, - Popover, Select, SideSheet, Space, @@ -295,34 +294,17 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => { }, }, { - title: t('权益'), - key: 'items', - width: 80, + title: t('总额度'), + key: 'total', + width: 120, render: (_, record) => { - const items = record?.items || []; - if (items.length === 0) return -; - const content = ( -
- {items.map((it) => ( -
- {it.model_name} - - {it.amount_used}/{it.amount_total} - {it.quota_type === 1 ? t('次') : ''} - -
- ))} -
- ); + const sub = record?.subscription; + const total = Number(sub?.amount_total || 0); + const used = Number(sub?.amount_used || 0); return ( - - - {items.length} {t('项')} - - + 0 ? 'secondary' : 'tertiary'}> + {total > 0 ? `${used}/${total}` : t('不限')} + ); }, }, diff --git a/web/src/components/topup/SubscriptionPlansCard.jsx b/web/src/components/topup/SubscriptionPlansCard.jsx index 38b38b675..adead2379 100644 --- a/web/src/components/topup/SubscriptionPlansCard.jsx +++ b/web/src/components/topup/SubscriptionPlansCard.jsx @@ -33,7 +33,7 @@ import { } from '@douyinfe/semi-ui'; import { API, showError, showSuccess } from '../../helpers'; import { getCurrencyConfig, stringToColor } from '../../helpers/render'; -import { CalendarClock, Check, Crown, RefreshCw, Sparkles } from 'lucide-react'; +import { Crown, RefreshCw, Sparkles } from 'lucide-react'; import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal'; const { Text } = Typography; @@ -245,16 +245,10 @@ const SubscriptionPlansCard = ({ // 计算单个订阅的使用进度 const getUsagePercent = (sub) => { - const items = sub?.items || []; - if (items.length === 0) return 0; - let totalUsed = 0; - let totalAmount = 0; - items.forEach((it) => { - totalUsed += Number(it.amount_used || 0); - totalAmount += Number(it.amount_total || 0); - }); - if (totalAmount === 0) return 0; - return Math.round((totalUsed / totalAmount) * 100); + const total = Number(sub?.subscription?.amount_total || 0); + const used = Number(sub?.subscription?.amount_used || 0); + if (total <= 0) return 0; + return Math.round((used / total) * 100); }; return ( @@ -374,7 +368,12 @@ const SubscriptionPlansCard = ({ {allSubscriptions.map((sub, subIndex) => { const isLast = subIndex === allSubscriptions.length - 1; const subscription = sub.subscription; - const items = sub.items || []; + const totalAmount = Number(subscription?.amount_total || 0); + const usedAmount = Number(subscription?.amount_used || 0); + const remainAmount = + totalAmount > 0 + ? Math.max(0, totalAmount - usedAmount) + : 0; const remainDays = getRemainingDays(sub); const usagePercent = getUsagePercent(sub); const now = Date.now() / 1000; @@ -418,34 +417,17 @@ const SubscriptionPlansCard = ({ (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} - - )} -
- )} +
+ {t('总额度')}:{' '} + {totalAmount > 0 + ? `${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}` + : t('不限')} + {totalAmount > 0 && ( + + {t('已用')} {usagePercent}% + + )} +
{!isLast && } ); @@ -464,7 +446,7 @@ const SubscriptionPlansCard = ({
{plans.map((p, index) => { const plan = p?.plan; - const planItems = p?.items || []; + const totalAmount = Number(plan?.total_amount || 0); const { symbol, rate } = getCurrencyConfig(); const price = Number(plan?.price_amount || 0); const displayPrice = (price * rate).toFixed( @@ -474,10 +456,14 @@ const SubscriptionPlansCard = ({ const limit = Number(plan?.max_purchase_per_user || 0); const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : t('不限购'); + const totalLabel = + totalAmount > 0 + ? `${t('总额度')}: ${totalAmount}` + : `${t('总额度')}: ${t('不限')}`; const planTags = [ `${t('有效期')}: ${formatDuration(plan, t)}`, `${t('重置')}: ${formatResetPeriod(plan, t)}`, - `${t('权益')}: ${planItems.length} ${t('项')}`, + totalLabel, limitLabel, ]; @@ -548,35 +534,6 @@ const SubscriptionPlansCard = ({ - {/* 权益列表 */} -
- {planItems.slice(0, 5).map((it, idx) => ( -
- - - {it.model_name} - - - {it.amount_total} - {it.quota_type === 1 ? t('次') : ''} - -
- ))} - {planItems.length > 5 && ( -
- +{planItems.length - 5} {t('项更多权益')} -
- )} - {planItems.length === 0 && ( -
- {t('暂无权益配置')} -
- )} -
- {/* 购买按钮 */} {(() => { const count = getPlanPurchaseCount(p?.plan?.id); diff --git a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx index 74b62c48f..c914c7e77 100644 --- a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx +++ b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx @@ -23,12 +23,11 @@ import { Modal, Typography, Card, - Tag, Button, Select, Divider, } from '@douyinfe/semi-ui'; -import { Crown, CalendarClock, Package, Check } from 'lucide-react'; +import { Crown, CalendarClock, Package } from 'lucide-react'; import { SiStripe } from 'react-icons/si'; import { IconCreditCard } from '@douyinfe/semi-icons'; import { getCurrencyConfig } from '../../../helpers/render'; @@ -89,7 +88,7 @@ const SubscriptionPurchaseModal = ({ onPayEpay, }) => { const plan = selectedPlan?.plan; - const items = selectedPlan?.items || []; + const totalAmount = Number(plan?.total_amount || 0); const { symbol, rate } = getCurrencyConfig(); const price = plan ? Number(plan.price_amount || 0) : 0; const displayPrice = (price * rate).toFixed(price % 1 === 0 ? 0 : 2); @@ -156,12 +155,12 @@ const SubscriptionPurchaseModal = ({
- {t('包含权益')}: + {t('总额度')}:
- {items.length} {t('项')} + {totalAmount > 0 ? totalAmount : t('不限')}
@@ -178,35 +177,6 @@ const SubscriptionPurchaseModal = ({ - {/* 权益列表 */} - {items.length > 0 && ( -
- - {t('权益明细')}: - -
- {items.slice(0, 6).map((it, idx) => ( - - - {it.model_name}: {it.amount_total} - {it.quota_type === 1 ? t('次') : ''} - - ))} - {items.length > 6 && ( - - +{items.length - 6} - - )} -
-
- )} - {/* 支付方式 */} {purchaseLimitReached && ( { // State management const [allPlans, setAllPlans] = useState([]); const [loading, setLoading] = useState(true); - const [pricingModels, setPricingModels] = useState([]); // Pagination (client-side for now) const [activePage, setActivePage] = useState(1); @@ -40,18 +39,6 @@ export const useSubscriptionsData = () => { const [editingPlan, setEditingPlan] = useState(null); const [sheetPlacement, setSheetPlacement] = useState('left'); // 'left' | 'right' - // Load pricing models for dropdown - const loadModels = async () => { - try { - const res = await API.get('/api/pricing'); - if (res.data?.success) { - setPricingModels(res.data.data || []); - } - } catch (e) { - setPricingModels([]); - } - }; - // Load subscription plans const loadPlans = async () => { setLoading(true); @@ -133,7 +120,6 @@ export const useSubscriptionsData = () => { // Initialize data on component mount useEffect(() => { - loadModels(); loadPlans(); }, []); @@ -148,7 +134,6 @@ export const useSubscriptionsData = () => { plans, planCount, loading, - pricingModels, // Modal state showEdit, diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 3b12b8daa..8741c60a4 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -517,14 +517,11 @@ export const useLogsData = () => { if (other?.billing_source === 'subscription') { const planId = other?.subscription_plan_id; const planTitle = other?.subscription_plan_title || ''; - const itemId = other?.subscription_item_id; - const quotaType = other?.subscription_quota_type; - const unit = quotaType === 1 ? t('次') : t('额度'); + const subscriptionId = other?.subscription_id; + const unit = t('额度'); const pre = other?.subscription_pre_consumed ?? 0; const postDelta = other?.subscription_post_delta ?? 0; - const finalConsumed = - other?.subscription_consumed ?? - (quotaType === 1 ? 1 : pre + postDelta); + const finalConsumed = other?.subscription_consumed ?? pre + postDelta; const remain = other?.subscription_remain; const total = other?.subscription_total; // Use multiple Description items to avoid an overlong single line. @@ -534,20 +531,15 @@ export const useLogsData = () => { value: `#${planId} ${planTitle}`.trim(), }); } - if (itemId) { + if (subscriptionId) { expandDataLocal.push({ - key: t('订阅权益'), - value: - quotaType === 1 - ? `${t('权益ID')} ${itemId} · ${t('按次')}(1 ${t('次')}/${t('请求')})` - : `${t('权益ID')} ${itemId} · ${t('按量')}`, + key: t('订阅实例'), + value: `#${subscriptionId}`, }); } const settlementLines = [ `${t('预扣')}:${pre} ${unit}`, - quotaType === 0 - ? `${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}` - : null, + `${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`, `${t('最终抵扣')}:${finalConsumed} ${unit}`, ] .filter(Boolean)