fix: preserve purchased subscription quota reset behavior
This commit is contained in:
+145
-37
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user