fix: preserve purchased subscription quota reset behavior

This commit is contained in:
Lich-Mac-Mini
2026-04-09 20:19:55 +08:00
parent 8967a69af9
commit 0818d33384
2 changed files with 153 additions and 53 deletions
+145 -37
View File
@@ -234,6 +234,8 @@ type UserSubscription struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id" gorm:"index;index:idx_user_sub_active,priority:1"` UserId int `json:"user_id" gorm:"index;index:idx_user_sub_active,priority:1"`
PlanId int `json:"plan_id" gorm:"index"` 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"` AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"`
AmountUsed int64 `json:"amount_used" 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"` LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"`
NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"` 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:''"` UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
PrevUserGroup string `json:"prev_user_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 { func normalizeUserSubscriptionResetPeriod(period string) string {
if plan == nil { switch strings.TrimSpace(period) {
return 0 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 return 0
} }
var next time.Time 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()). next = time.Date(base.Year(), base.Month(), 1, 0, 0, 0, 0, base.Location()).
AddDate(0, 1, 0) AddDate(0, 1, 0)
case SubscriptionResetCustom: case SubscriptionResetCustom:
if plan.QuotaResetCustomSeconds <= 0 { if customSeconds <= 0 {
return 0 return 0
} }
next = base.Add(time.Duration(plan.QuotaResetCustomSeconds) * time.Second) next = base.Add(time.Duration(customSeconds) * time.Second)
default: default:
return 0 return 0
} }
@@ -346,6 +357,92 @@ func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) in
return next.Unix() 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) { func GetSubscriptionPlanById(id int) (*SubscriptionPlan, error) {
return getSubscriptionPlanByIdTx(nil, id) return getSubscriptionPlanByIdTx(nil, id)
} }
@@ -483,20 +580,23 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
} }
} }
sub := &UserSubscription{ sub := &UserSubscription{
UserId: userId, UserId: userId,
PlanId: plan.Id, PlanId: plan.Id,
AmountTotal: plan.TotalAmount, PlanTitle: plan.Title,
AmountUsed: 0, AmountTotal: plan.TotalAmount,
StartTime: now.Unix(), AmountUsed: 0,
EndTime: endUnix, StartTime: now.Unix(),
Status: "active", EndTime: endUnix,
Source: source, Status: "active",
LastResetTime: lastReset, Source: source,
NextResetTime: nextReset, LastResetTime: lastReset,
UpgradeGroup: upgradeGroup, NextResetTime: nextReset,
PrevUserGroup: prevGroup, QuotaResetPeriod: NormalizeResetPeriod(plan.QuotaResetPeriod),
CreatedAt: common.GetTimestamp(), QuotaResetCustomSeconds: plan.QuotaResetCustomSeconds,
UpdatedAt: common.GetTimestamp(), UpgradeGroup: upgradeGroup,
PrevUserGroup: prevGroup,
CreatedAt: common.GetTimestamp(),
UpdatedAt: common.GetTimestamp(),
} }
if err := tx.Create(sub).Error; err != nil { if err := tx.Create(sub).Error; err != nil {
return nil, err return nil, err
@@ -709,6 +809,10 @@ func buildSubscriptionSummaries(subs []UserSubscription) []SubscriptionSummary {
result := make([]SubscriptionSummary, 0, len(subs)) result := make([]SubscriptionSummary, 0, len(subs))
for _, sub := range subs { for _, sub := range subs {
subCopy := sub subCopy := sub
if period, customSeconds, ok := resolveUserSubscriptionResetConfig(&subCopy); ok {
subCopy.QuotaResetPeriod = period
subCopy.QuotaResetCustomSeconds = customSeconds
}
result = append(result, SubscriptionSummary{ result = append(result, SubscriptionSummary{
Subscription: &subCopy, Subscription: &subCopy,
}) })
@@ -921,14 +1025,18 @@ func (r *SubscriptionPreConsumeRecord) BeforeUpdate(tx *gorm.DB) error {
return nil return nil
} }
func maybeResetUserSubscriptionWithPlanTx(tx *gorm.DB, sub *UserSubscription, plan *SubscriptionPlan, now int64) error { func maybeResetUserSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now int64) error {
if tx == nil || sub == nil || plan == nil { if tx == nil || sub == nil {
return errors.New("invalid reset args") return errors.New("invalid reset args")
} }
period, customSeconds, err := ensureUserSubscriptionSnapshotTx(tx, sub)
if err != nil {
return err
}
if sub.NextResetTime > 0 && sub.NextResetTime > now { if sub.NextResetTime > 0 && sub.NextResetTime > now {
return nil return nil
} }
if NormalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever { if period == "" || period == SubscriptionResetNever {
return nil return nil
} }
baseUnix := sub.LastResetTime baseUnix := sub.LastResetTime
@@ -936,12 +1044,12 @@ func maybeResetUserSubscriptionWithPlanTx(tx *gorm.DB, sub *UserSubscription, pl
baseUnix = sub.StartTime baseUnix = sub.StartTime
} }
base := time.Unix(baseUnix, 0) base := time.Unix(baseUnix, 0)
next := calcNextResetTime(base, plan, sub.EndTime) next := calcNextResetTimeByConfig(base, period, customSeconds, sub.EndTime)
advanced := false advanced := false
for next > 0 && next <= now { for next > 0 && next <= now {
advanced = true advanced = true
base = time.Unix(next, 0) base = time.Unix(next, 0)
next = calcNextResetTime(base, plan, sub.EndTime) next = calcNextResetTimeByConfig(base, period, customSeconds, sub.EndTime)
} }
if !advanced { if !advanced {
if sub.NextResetTime == 0 && next > 0 { if sub.NextResetTime == 0 && next > 0 {
@@ -1006,11 +1114,7 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string,
} }
for _, candidate := range subs { for _, candidate := range subs {
sub := candidate sub := candidate
plan, err := getSubscriptionPlanByIdTx(tx, sub.PlanId) if err := maybeResetUserSubscriptionTx(tx, &sub, now); err != nil {
if err != nil {
return err
}
if err := maybeResetUserSubscriptionWithPlanTx(tx, &sub, plan, now); err != nil {
return err return err
} }
usedBefore := sub.AmountUsed usedBefore := sub.AmountUsed
@@ -1106,18 +1210,14 @@ func ResetDueSubscriptions(limit int) (int, error) {
resetCount := 0 resetCount := 0
for _, sub := range subs { for _, sub := range subs {
subCopy := sub subCopy := sub
plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId) err := DB.Transaction(func(tx *gorm.DB) error {
if err != nil || plan == nil {
continue
}
err = DB.Transaction(func(tx *gorm.DB) error {
var locked UserSubscription var locked UserSubscription
if err := tx.Set("gorm:query_option", "FOR UPDATE"). if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("id = ? AND next_reset_time > 0 AND next_reset_time <= ?", subCopy.Id, now). Where("id = ? AND next_reset_time > 0 AND next_reset_time <= ?", subCopy.Id, now).
First(&locked).Error; err != nil { First(&locked).Error; err != nil {
return nil return nil
} }
if err := maybeResetUserSubscriptionWithPlanTx(tx, &locked, plan, now); err != nil { if err := maybeResetUserSubscriptionTx(tx, &locked, now); err != nil {
return err return err
} }
resetCount++ resetCount++
@@ -1157,6 +1257,14 @@ func GetSubscriptionPlanInfoByUserSubscriptionId(userSubscriptionId int) (*Subsc
if err := DB.Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil { if err := DB.Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil {
return nil, err 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) plan, err := getSubscriptionPlanByIdTx(nil, sub.PlanId)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -249,16 +249,6 @@ const SubscriptionPlansCard = ({
return map; return map;
}, [plans]); }, [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) => const getPlanPurchaseCount = (planId) =>
planPurchaseCountMap.get(planId) || 0; planPurchaseCountMap.get(planId) || 0;
@@ -414,14 +404,16 @@ const SubscriptionPlansCard = ({
totalAmount > 0 totalAmount > 0
? Math.max(0, totalAmount - usedAmount) ? Math.max(0, totalAmount - usedAmount)
: 0; : 0;
const plan = planMap.get(subscription?.plan_id);
const planTitle = const planTitle =
planTitleMap.get(subscription?.plan_id) || ''; subscription?.plan_title ||
const resetPeriod = plan planTitleMap.get(subscription?.plan_id) ||
? formatSubscriptionResetPeriod(plan, t) '';
: t('不重置'); const resetPeriod = formatSubscriptionResetPeriod(
subscription,
t,
);
const quotaLabel = const quotaLabel =
plan && resetPeriod !== t('不重置') resetPeriod !== t('不重置')
? `${resetPeriod}${t('额度')}` ? `${resetPeriod}${t('额度')}`
: t('总额度'); : t('总额度');
const remainDays = getRemainingDays(sub); const remainDays = getRemainingDays(sub);