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"`
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user