mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 05:17:27 +00:00
🚀 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:
@@ -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)
|
||||
|
||||
@@ -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缓冲区足够大
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('操作'),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user