diff --git a/model/subscription.go b/model/subscription.go index d21e948f..43210051 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -234,6 +234,8 @@ type UserSubscription struct { Id int `json:"id"` UserId int `json:"user_id" gorm:"index;index:idx_user_sub_active,priority:1"` PlanId int `json:"plan_id" gorm:"index"` + // Snapshot fields preserve the purchased plan semantics for historical subscriptions. + PlanTitle string `json:"plan_title" gorm:"type:varchar(128);default:''"` AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"` AmountUsed int64 `json:"amount_used" gorm:"type:bigint;not null;default:0"` @@ -246,6 +248,9 @@ type UserSubscription struct { LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"` NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"` + // Empty QuotaResetPeriod means legacy data without a snapshot yet. + QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:''"` + QuotaResetCustomSeconds int64 `json:"quota_reset_custom_seconds" gorm:"type:bigint;default:0"` UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"` PrevUserGroup string `json:"prev_user_group" gorm:"type:varchar(64);default:''"` @@ -305,12 +310,18 @@ func NormalizeResetPeriod(period string) string { } } -func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) int64 { - if plan == nil { - return 0 +func normalizeUserSubscriptionResetPeriod(period string) string { + switch strings.TrimSpace(period) { + case SubscriptionResetNever, SubscriptionResetDaily, SubscriptionResetWeekly, SubscriptionResetMonthly, SubscriptionResetCustom: + return strings.TrimSpace(period) + default: + return "" } - period := NormalizeResetPeriod(plan.QuotaResetPeriod) - if period == SubscriptionResetNever { +} + +func calcNextResetTimeByConfig(base time.Time, period string, customSeconds int64, endUnix int64) int64 { + period = normalizeUserSubscriptionResetPeriod(period) + if period == "" || period == SubscriptionResetNever { return 0 } var next time.Time @@ -333,10 +344,10 @@ func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) in next = time.Date(base.Year(), base.Month(), 1, 0, 0, 0, 0, base.Location()). AddDate(0, 1, 0) case SubscriptionResetCustom: - if plan.QuotaResetCustomSeconds <= 0 { + if customSeconds <= 0 { return 0 } - next = base.Add(time.Duration(plan.QuotaResetCustomSeconds) * time.Second) + next = base.Add(time.Duration(customSeconds) * time.Second) default: return 0 } @@ -346,6 +357,92 @@ func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) in return next.Unix() } +func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) int64 { + if plan == nil { + return 0 + } + return calcNextResetTimeByConfig(base, NormalizeResetPeriod(plan.QuotaResetPeriod), plan.QuotaResetCustomSeconds, endUnix) +} + +func inferUserSubscriptionResetConfig(sub *UserSubscription) (string, int64, bool) { + if sub == nil { + return "", 0, false + } + baseUnix := sub.LastResetTime + if baseUnix <= 0 { + baseUnix = sub.StartTime + } + if baseUnix <= 0 || sub.NextResetTime <= 0 || sub.NextResetTime <= baseUnix { + return "", 0, false + } + base := time.Unix(baseUnix, 0) + for _, period := range []string{ + SubscriptionResetDaily, + SubscriptionResetWeekly, + SubscriptionResetMonthly, + } { + if calcNextResetTimeByConfig(base, period, 0, sub.EndTime) == sub.NextResetTime { + return period, 0, true + } + } + customSeconds := sub.NextResetTime - baseUnix + if customSeconds > 0 { + return SubscriptionResetCustom, customSeconds, true + } + return "", 0, false +} + +func resolveUserSubscriptionResetConfig(sub *UserSubscription) (string, int64, bool) { + if sub == nil { + return "", 0, false + } + period := normalizeUserSubscriptionResetPeriod(sub.QuotaResetPeriod) + if period != "" { + if period == SubscriptionResetCustom && sub.QuotaResetCustomSeconds <= 0 { + return "", 0, false + } + return period, sub.QuotaResetCustomSeconds, true + } + return inferUserSubscriptionResetConfig(sub) +} + +func ensureUserSubscriptionSnapshotTx(tx *gorm.DB, sub *UserSubscription) (string, int64, error) { + if tx == nil || sub == nil { + return "", 0, errors.New("invalid snapshot args") + } + updateMap := map[string]interface{}{} + if strings.TrimSpace(sub.PlanTitle) == "" { + if plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId); err == nil && plan != nil { + sub.PlanTitle = plan.Title + if strings.TrimSpace(sub.PlanTitle) != "" { + updateMap["plan_title"] = sub.PlanTitle + } + } + } + period, customSeconds, ok := resolveUserSubscriptionResetConfig(sub) + if ok && (normalizeUserSubscriptionResetPeriod(sub.QuotaResetPeriod) != period || + (period == SubscriptionResetCustom && sub.QuotaResetCustomSeconds != customSeconds)) { + sub.QuotaResetPeriod = period + sub.QuotaResetCustomSeconds = customSeconds + updateMap["quota_reset_period"] = period + updateMap["quota_reset_custom_seconds"] = customSeconds + } + if len(updateMap) == 0 { + if !ok { + return "", 0, nil + } + return period, customSeconds, nil + } + updateMap["updated_at"] = common.GetTimestamp() + if err := tx.Model(&UserSubscription{}).Where("id = ?", sub.Id).Updates(updateMap).Error; err != nil { + return "", 0, err + } + if !ok { + return "", 0, nil + } + return period, customSeconds, nil +} + func GetSubscriptionPlanById(id int) (*SubscriptionPlan, error) { return getSubscriptionPlanByIdTx(nil, id) } @@ -483,20 +580,23 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio } } sub := &UserSubscription{ - UserId: userId, - PlanId: plan.Id, - AmountTotal: plan.TotalAmount, - AmountUsed: 0, - StartTime: now.Unix(), - EndTime: endUnix, - Status: "active", - Source: source, - LastResetTime: lastReset, - NextResetTime: nextReset, - UpgradeGroup: upgradeGroup, - PrevUserGroup: prevGroup, - CreatedAt: common.GetTimestamp(), - UpdatedAt: common.GetTimestamp(), + UserId: userId, + PlanId: plan.Id, + PlanTitle: plan.Title, + AmountTotal: plan.TotalAmount, + AmountUsed: 0, + StartTime: now.Unix(), + EndTime: endUnix, + Status: "active", + Source: source, + LastResetTime: lastReset, + NextResetTime: nextReset, + QuotaResetPeriod: NormalizeResetPeriod(plan.QuotaResetPeriod), + QuotaResetCustomSeconds: plan.QuotaResetCustomSeconds, + UpgradeGroup: upgradeGroup, + PrevUserGroup: prevGroup, + CreatedAt: common.GetTimestamp(), + UpdatedAt: common.GetTimestamp(), } if err := tx.Create(sub).Error; err != nil { return nil, err @@ -709,6 +809,10 @@ func buildSubscriptionSummaries(subs []UserSubscription) []SubscriptionSummary { result := make([]SubscriptionSummary, 0, len(subs)) for _, sub := range subs { subCopy := sub + if period, customSeconds, ok := resolveUserSubscriptionResetConfig(&subCopy); ok { + subCopy.QuotaResetPeriod = period + subCopy.QuotaResetCustomSeconds = customSeconds + } result = append(result, SubscriptionSummary{ Subscription: &subCopy, }) @@ -921,14 +1025,18 @@ func (r *SubscriptionPreConsumeRecord) BeforeUpdate(tx *gorm.DB) error { return nil } -func maybeResetUserSubscriptionWithPlanTx(tx *gorm.DB, sub *UserSubscription, plan *SubscriptionPlan, now int64) error { - if tx == nil || sub == nil || plan == nil { +func maybeResetUserSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now int64) error { + if tx == nil || sub == nil { return errors.New("invalid reset args") } + period, customSeconds, err := ensureUserSubscriptionSnapshotTx(tx, sub) + if err != nil { + return err + } if sub.NextResetTime > 0 && sub.NextResetTime > now { return nil } - if NormalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever { + if period == "" || period == SubscriptionResetNever { return nil } baseUnix := sub.LastResetTime @@ -936,12 +1044,12 @@ func maybeResetUserSubscriptionWithPlanTx(tx *gorm.DB, sub *UserSubscription, pl baseUnix = sub.StartTime } base := time.Unix(baseUnix, 0) - next := calcNextResetTime(base, plan, sub.EndTime) + next := calcNextResetTimeByConfig(base, period, customSeconds, sub.EndTime) advanced := false for next > 0 && next <= now { advanced = true base = time.Unix(next, 0) - next = calcNextResetTime(base, plan, sub.EndTime) + next = calcNextResetTimeByConfig(base, period, customSeconds, sub.EndTime) } if !advanced { if sub.NextResetTime == 0 && next > 0 { @@ -1006,11 +1114,7 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string, } for _, candidate := range subs { sub := candidate - plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId) - if err != nil { - return err - } - if err := maybeResetUserSubscriptionWithPlanTx(tx, &sub, plan, now); err != nil { + if err := maybeResetUserSubscriptionTx(tx, &sub, now); err != nil { return err } usedBefore := sub.AmountUsed @@ -1106,18 +1210,14 @@ func ResetDueSubscriptions(limit int) (int, error) { resetCount := 0 for _, sub := range subs { subCopy := sub - plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId) - if err != nil || plan == nil { - continue - } - err = DB.Transaction(func(tx *gorm.DB) error { + 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 <= ?", subCopy.Id, now). First(&locked).Error; err != nil { return nil } - if err := maybeResetUserSubscriptionWithPlanTx(tx, &locked, plan, now); err != nil { + if err := maybeResetUserSubscriptionTx(tx, &locked, now); err != nil { return err } resetCount++ @@ -1157,6 +1257,14 @@ func GetSubscriptionPlanInfoByUserSubscriptionId(userSubscriptionId int) (*Subsc if err := DB.Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil { return nil, err } + if strings.TrimSpace(sub.PlanTitle) != "" { + info := &SubscriptionPlanInfo{ + PlanId: sub.PlanId, + PlanTitle: sub.PlanTitle, + } + _ = getSubscriptionPlanInfoCache().SetWithTTL(cacheKey, *info, subscriptionPlanInfoCacheTTL()) + return info, nil + } plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId) if err != nil { return nil, err diff --git a/web/src/components/topup/SubscriptionPlansCard.jsx b/web/src/components/topup/SubscriptionPlansCard.jsx index 153674e5..077dcb0e 100644 --- a/web/src/components/topup/SubscriptionPlansCard.jsx +++ b/web/src/components/topup/SubscriptionPlansCard.jsx @@ -249,16 +249,6 @@ const SubscriptionPlansCard = ({ return map; }, [plans]); - const planMap = useMemo(() => { - const map = new Map(); - (plans || []).forEach((p) => { - const plan = p?.plan; - if (!plan?.id) return; - map.set(plan.id, plan); - }); - return map; - }, [plans]); - const getPlanPurchaseCount = (planId) => planPurchaseCountMap.get(planId) || 0; @@ -414,14 +404,16 @@ const SubscriptionPlansCard = ({ totalAmount > 0 ? Math.max(0, totalAmount - usedAmount) : 0; - const plan = planMap.get(subscription?.plan_id); const planTitle = - planTitleMap.get(subscription?.plan_id) || ''; - const resetPeriod = plan - ? formatSubscriptionResetPeriod(plan, t) - : t('不重置'); + subscription?.plan_title || + planTitleMap.get(subscription?.plan_id) || + ''; + const resetPeriod = formatSubscriptionResetPeriod( + subscription, + t, + ); const quotaLabel = - plan && resetPeriod !== t('不重置') + resetPeriod !== t('不重置') ? `${resetPeriod}${t('额度')}` : t('总额度'); const remainDays = getRemainingDays(sub);