🚀 refactor: Simplify subscription quota to total amount model

Remove per-model subscription items and switch to a single total quota per plan and user subscription. Update billing, reset, and logging flows to operate on total quota, and refactor admin/user UI to configure and display total quota consistently.
This commit is contained in:
t0ng7u
2026-02-01 00:35:08 +08:00
parent b92a4ee987
commit 6300c31d70
17 changed files with 270 additions and 999 deletions

View File

@@ -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)

View File

@@ -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缓冲区足够大

View File

@@ -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
})
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = (
<div style={{ width: 260 }}>
<Text strong>{text}</Text>
@@ -84,6 +80,8 @@ const renderPlanTitle = (text, record, t) => {
<Text strong style={{ color: 'var(--semi-color-success)' }}>
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
</Text>
<Text type='tertiary'>{t('总额度')}</Text>
<Text>{plan?.total_amount > 0 ? plan.total_amount : t('不限')}</Text>
<Text type='tertiary'>{t('购买上限')}</Text>
<Text>
{plan?.max_purchase_per_user > 0
@@ -94,10 +92,6 @@ 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>
{items.length} {t('个')}
</Text>
</div>
</div>
);
@@ -165,54 +159,12 @@ const renderEnabled = (text, record, t) => {
);
};
const renderModels = (text, record, t) => {
const items = record?.items || [];
if (items.length === 0) {
return <Text type='tertiary'></Text>;
}
const popoverContent = (
<div style={{ maxWidth: 320, maxHeight: 260, overflowY: 'auto' }}>
<Text strong>
{t('模型权益')} ({items.length})
</Text>
<Divider margin={8} />
<Space vertical align='start' spacing={6}>
{items.map((it, idx) => (
<div
key={idx}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
gap: 12,
}}
>
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 180 }}>
{it.model_name}
</Text>
<Space spacing={8}>
<Tag
color={it.quota_type === 1 ? 'amber' : 'teal'}
shape='circle'
>
{quotaTypeLabel(it.quota_type)}
</Tag>
<Text type='secondary'>{it.amount_total}</Text>
</Space>
</div>
))}
</Space>
</div>
);
const renderTotalAmount = (text, record, t) => {
const total = Number(record?.plan?.total_amount || 0);
return (
<Popover content={popoverContent} position='leftTop' showArrow>
<Tag color='blue' shape='circle' style={{ cursor: 'pointer' }}>
{items.length} {t('个模型')}
</Tag>
</Popover>
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
{total > 0 ? total : t('不限')}
</Text>
);
};
@@ -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('操作'),

View File

@@ -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}
/>

View File

@@ -17,22 +17,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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) => (
<div className='text-sm'>
<div className='font-medium'>{v}</div>
<div className='text-xs text-gray-500'>
{t('计费')}: {quotaTypeLabel(row.quota_type)}
</div>
</div>
),
},
{
title: t('数量'),
dataIndex: 'amount_total',
width: 220,
render: (v, row, idx) => (
<InputNumber
value={Number(v || 0)}
min={0}
precision={0}
onChange={(val) => updateItem(idx, { amount_total: val })}
placeholder={row.quota_type === 1 ? t('次数') : t('额度')}
style={{ width: '100%' }}
/>
),
},
{
title: '',
width: 60,
render: (_, __, idx) => (
<Button
type='danger'
theme='borderless'
icon={<Trash2 size={14} />}
onClick={() => removeItem(idx)}
/>
),
},
];
return (
<>
<SideSheet
@@ -382,7 +196,7 @@ const AddEditSubscriptionModal = ({
}
bodyStyle={{ padding: '0' }}
visible={visible}
width={isMobile ? '100%' : 700}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<Space>
@@ -470,6 +284,27 @@ const AddEditSubscriptionModal = ({
/>
</Col>
<Col span={12}>
<Form.AutoComplete
field='total_amount'
label={t('总额度')}
type='number'
rules={[{ required: true, message: t('请输入总额度') }]}
extraText={`${t('0 表示不限')} · ${renderQuotaWithPrompt(
Number(values.total_amount || 0),
)}`}
data={[
{ value: 500000, label: '1' },
{ value: 5000000, label: '10' },
{ value: 25000000, label: '50' },
{ value: 50000000, label: '100' },
{ value: 250000000, label: '500' },
{ value: 500000000, label: '1000' },
]}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<Form.Input
field='currency'
@@ -665,123 +500,6 @@ const AddEditSubscriptionModal = ({
</Col>
</Row>
</Card>
{/* 模型权益 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center justify-between mb-3 gap-3'>
<div className='flex items-center'>
<Avatar
size='small'
color='orange'
className='mr-2 shadow-md'
>
<Boxes size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('模型权益')}
</Text>
<div className='text-xs text-gray-600'>
{t('配置套餐可使用的模型及额度')}
</div>
</div>
</div>
</div>
{/* 工具栏:最少步骤完成“添加 + 批量设置” */}
<div className='flex flex-col gap-2 mb-3'>
<div className='flex flex-col md:flex-row gap-2 md:items-center'>
<Select
placeholder={t('选择模型(可多选)')}
multiple
filter
value={pendingModels}
onChange={setPendingModels}
style={{ width: '100%', flex: 1 }}
>
{modelOptions.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Select>
<InputNumber
value={Number(defaultNewAmountTotal || 0)}
min={0}
precision={0}
onChange={(v) => setDefaultNewAmountTotal(v)}
style={{ width: isMobile ? '100%' : 180 }}
placeholder={t('默认数量')}
/>
<Button
theme='solid'
type='primary'
icon={<IconPlusCircle />}
onClick={addPendingModels}
>
{t('添加')}
</Button>
</div>
<div className='flex flex-col md:flex-row gap-2 md:items-center md:justify-between'>
<div className='flex items-center gap-2'>
<Tag color='white' shape='circle'>
{t('已选')} {selectedRowKeys?.length || 0}
</Tag>
<InputNumber
value={Number(bulkAmountTotal || 0)}
min={0}
precision={0}
onChange={(v) => setBulkAmountTotal(v)}
style={{ width: isMobile ? '100%' : 220 }}
placeholder={t('统一设置数量')}
/>
</div>
<div className='flex items-center gap-2 justify-end'>
<Button
theme='light'
type='primary'
onClick={() =>
applyBulkAmountTotal({ scope: 'selected' })
}
>
{t('应用到选中')}
</Button>
<Button
theme='light'
type='primary'
onClick={() => applyBulkAmountTotal({ scope: 'all' })}
>
{t('应用到全部')}
</Button>
<Button
theme='light'
type='danger'
icon={<Trash2 size={14} />}
onClick={deleteSelectedItems}
>
{t('删除选中')}
</Button>
</div>
</div>
</div>
<Table
columns={itemColumns}
dataSource={items}
pagination={false}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys || []),
}}
rowKey={(row) => `${row.model_name}-${row.quota_type}`}
empty={
<div className='py-6 text-center text-gray-500'>
{t('尚未添加任何模型')}
</div>
}
/>
</Card>
</div>
)}
</Form>

View File

@@ -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 <Text type='tertiary'>-</Text>;
const content = (
<div className='max-w-[320px] space-y-1'>
{items.map((it) => (
<div
key={`${it.id}-${it.model_name}`}
className='flex justify-between text-xs'
>
<span className='truncate mr-2'>{it.model_name}</span>
<span className='text-gray-600'>
{it.amount_used}/{it.amount_total}
{it.quota_type === 1 ? t('次') : ''}
</span>
</div>
))}
</div>
);
const sub = record?.subscription;
const total = Number(sub?.amount_total || 0);
const used = Number(sub?.amount_used || 0);
return (
<Popover content={content} position='top' showArrow>
<Tag color='white' shape='circle'>
{items.length} {t('项')}
</Tag>
</Popover>
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
{total > 0 ? `${used}/${total}` : t('不限')}
</Text>
);
},
},

View File

@@ -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()}
</div>
{/* 权益列表 */}
{items.length > 0 && (
<div className='flex flex-wrap gap-1'>
{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 (
<Tag
key={`${it.id}-${it.model_name}`}
size='small'
color='white'
shape='circle'
>
{it.model_name}: {remain}
{label}
</Tag>
);
})}
{items.length > 4 && (
<Tag size='small' color='white' shape='circle'>
+{items.length - 4}
</Tag>
)}
</div>
)}
<div className='text-xs text-gray-500 mb-2'>
{t('总额度')}:{' '}
{totalAmount > 0
? `${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`
: t('不限')}
{totalAmount > 0 && (
<span className='ml-2'>
{t('已用')} {usagePercent}%
</span>
)}
</div>
{!isLast && <Divider margin={12} />}
</div>
);
@@ -464,7 +446,7 @@ const SubscriptionPlansCard = ({
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
{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 = ({
<Divider margin={12} />
{/* 权益列表 */}
<div className='space-y-2 mb-4'>
{planItems.slice(0, 5).map((it, idx) => (
<div key={idx} className='flex items-center text-sm'>
<Check
size={14}
className='text-green-500 mr-2 flex-shrink-0'
/>
<span className='truncate flex-1'>
{it.model_name}
</span>
<Tag size='small' color='white' shape='circle'>
{it.amount_total}
{it.quota_type === 1 ? t('次') : ''}
</Tag>
</div>
))}
{planItems.length > 5 && (
<div className='text-xs text-gray-400 text-center'>
+{planItems.length - 5} {t('项更多权益')}
</div>
)}
{planItems.length === 0 && (
<div className='text-xs text-gray-400 text-center py-2'>
{t('暂无权益配置')}
</div>
)}
</div>
{/* 购买按钮 */}
{(() => {
const count = getPlanPurchaseCount(p?.plan?.id);

View File

@@ -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 = ({
</div>
<div className='flex justify-between items-center'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{t('包含权益')}
{t('总额度')}
</Text>
<div className='flex items-center'>
<Package size={14} className='mr-1 text-slate-500' />
<Text className='text-slate-900 dark:text-slate-100'>
{items.length} {t('')}
{totalAmount > 0 ? totalAmount : t('不限')}
</Text>
</div>
</div>
@@ -178,35 +177,6 @@ const SubscriptionPurchaseModal = ({
</div>
</Card>
{/* 权益列表 */}
{items.length > 0 && (
<div className='space-y-2'>
<Text size='small' type='tertiary'>
{t('权益明细')}
</Text>
<div className='flex flex-wrap gap-1'>
{items.slice(0, 6).map((it, idx) => (
<Tag
key={idx}
size='small'
color='white'
type='light'
shape='circle'
>
<Check size={10} className='mr-1' />
{it.model_name}: {it.amount_total}
{it.quota_type === 1 ? t('次') : ''}
</Tag>
))}
{items.length > 6 && (
<Tag size='small' color='white' type='light' shape='circle'>
+{items.length - 6}
</Tag>
)}
</div>
</div>
)}
{/* 支付方式 */}
{purchaseLimitReached && (
<Banner

View File

@@ -29,7 +29,6 @@ export const useSubscriptionsData = () => {
// 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,

View File

@@ -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)