From a4884b4e758bda7076074f17df2f2dd7376996bd Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 18 May 2026 21:09:11 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(subscription):=20=E5=B0=86=E6=97=A5?= =?UTF-8?q?=E5=8D=A1=E6=94=B9=E4=B8=BA=E4=B8=80=E6=AC=A1=E6=80=A7=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E9=85=8D=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../subscription_assign_idempotency_test.go | 18 ++ .../subscription_calculate_progress_test.go | 24 +++ .../internal/service/subscription_service.go | 133 +++++++++---- backend/internal/service/user_subscription.go | 20 +- .../user_subscription_daily_quota_test.go | 178 ++++++++++++++++++ 5 files changed, 334 insertions(+), 39 deletions(-) create mode 100644 backend/internal/service/user_subscription_daily_quota_test.go diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index 40bab206..c8ace613 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -199,6 +199,24 @@ func (s *subscriptionUserSubRepoStub) GetByID(_ context.Context, id int64) (*Use return &cp, nil } +func (s *subscriptionUserSubRepoStub) Update(_ context.Context, sub *UserSubscription) error { + if sub == nil { + return ErrSubscriptionNilInput + } + existing := s.byID[sub.ID] + if existing == nil { + return ErrSubscriptionNotFound + } + oldKey := s.key(existing.UserID, existing.GroupID) + cp := *sub + s.byID[cp.ID] = &cp + if oldKey != s.key(cp.UserID, cp.GroupID) { + delete(s.byUserGroup, oldKey) + } + s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp + return nil +} + func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) { start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC) groupRepo := &subscriptionGroupRepoStub{ diff --git a/backend/internal/service/subscription_calculate_progress_test.go b/backend/internal/service/subscription_calculate_progress_test.go index 53e5c568..650522d5 100644 --- a/backend/internal/service/subscription_calculate_progress_test.go +++ b/backend/internal/service/subscription_calculate_progress_test.go @@ -66,6 +66,30 @@ func TestCalculateProgress_DailyUsage(t *testing.T) { assert.Equal(t, dailyStart, progress.Daily.WindowStart) } +func TestCalculateProgress_DailyCardUsesExpiryAsDailyResetTime(t *testing.T) { + svc := newTestSubscriptionService() + startsAt := time.Now().Add(-12 * time.Hour) + dailyStart := time.Date(startsAt.Year(), startsAt.Month(), startsAt.Day(), 0, 0, 0, 0, startsAt.Location()) + expiresAt := startsAt.Add(24 * time.Hour) + + sub := &UserSubscription{ + ID: 1, + StartsAt: startsAt, + ExpiresAt: expiresAt, + DailyUsageUSD: 3.0, + DailyWindowStart: ptrTime(dailyStart), + } + group := &Group{ + Name: "Daily", + DailyLimitUSD: ptrFloat64(10.0), + } + + progress := svc.calculateProgress(sub, group) + + require.NotNil(t, progress.Daily, "日卡有日限额和窗口时 Daily 不应为 nil") + assert.Equal(t, expiresAt, progress.Daily.ResetsAt, "日卡的一次性日额度结束时间应为订阅过期时间") +} + func TestCalculateProgress_WeeklyUsage(t *testing.T) { svc := newTestSubscriptionService() now := time.Now() diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index f0a5540e..9905e6a1 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -196,7 +196,8 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in now := time.Now() var newExpiresAt time.Time - if existingSub.ExpiresAt.After(now) { + isExpired := !existingSub.ExpiresAt.After(now) + if !isExpired { // 未过期:从当前过期时间累加 newExpiresAt = existingSub.ExpiresAt.AddDate(0, 0, validityDays) } else { @@ -209,43 +210,8 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in newExpiresAt = MaxExpiresAt } - // 开启事务:ExtendExpiry + UpdateStatus + UpdateNotes 在同一事务中完成 - tx, err := s.entClient.Tx(ctx) - if err != nil { - return nil, false, fmt.Errorf("begin transaction: %w", err) - } - txCtx := dbent.NewTxContext(ctx, tx) - - // 更新过期时间 - if err := s.userSubRepo.ExtendExpiry(txCtx, existingSub.ID, newExpiresAt); err != nil { - _ = tx.Rollback() - return nil, false, fmt.Errorf("extend subscription: %w", err) - } - - // 如果订阅已过期或被暂停,恢复为active状态 - if existingSub.Status != SubscriptionStatusActive { - if err := s.userSubRepo.UpdateStatus(txCtx, existingSub.ID, SubscriptionStatusActive); err != nil { - _ = tx.Rollback() - return nil, false, fmt.Errorf("update subscription status: %w", err) - } - } - - // 追加备注 - if input.Notes != "" { - newNotes := existingSub.Notes - if newNotes != "" { - newNotes += "\n" - } - newNotes += input.Notes - if err := s.userSubRepo.UpdateNotes(txCtx, existingSub.ID, newNotes); err != nil { - _ = tx.Rollback() - return nil, false, fmt.Errorf("update subscription notes: %w", err) - } - } - - // 提交事务 - if err := tx.Commit(); err != nil { - return nil, false, fmt.Errorf("commit transaction: %w", err) + if err := s.updateExistingSubscriptionTerm(ctx, existingSub, input.Notes, now, newExpiresAt, isExpired); err != nil { + return nil, false, err } // 失效订阅缓存 @@ -284,6 +250,94 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in return sub, false, nil // false 表示是新建 } +func (s *SubscriptionService) updateExistingSubscriptionTerm( + ctx context.Context, + existingSub *UserSubscription, + notes string, + startsAt time.Time, + newExpiresAt time.Time, + isExpired bool, +) error { + return s.withSubscriptionUpdateTx(ctx, func(txCtx context.Context) error { + if isExpired { + renewed := renewedSubscriptionTerm(existingSub, notes, startsAt, newExpiresAt) + if err := s.userSubRepo.Update(txCtx, renewed); err != nil { + return fmt.Errorf("renew expired subscription: %w", err) + } + return nil + } + + // 更新过期时间 + if err := s.userSubRepo.ExtendExpiry(txCtx, existingSub.ID, newExpiresAt); err != nil { + return fmt.Errorf("extend subscription: %w", err) + } + + // 如果订阅被暂停,恢复为 active 状态 + if existingSub.Status != SubscriptionStatusActive { + if err := s.userSubRepo.UpdateStatus(txCtx, existingSub.ID, SubscriptionStatusActive); err != nil { + return fmt.Errorf("update subscription status: %w", err) + } + } + + // 追加备注 + if notes != "" { + if err := s.userSubRepo.UpdateNotes(txCtx, existingSub.ID, appendSubscriptionNotes(existingSub.Notes, notes)); err != nil { + return fmt.Errorf("update subscription notes: %w", err) + } + } + + return nil + }) +} + +func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn func(context.Context) error) error { + if s.entClient == nil { + return fn(ctx) + } + + tx, err := s.entClient.Tx(ctx) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + txCtx := dbent.NewTxContext(ctx, tx) + + if err := fn(txCtx); err != nil { + _ = tx.Rollback() + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + +func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription { + renewed := *existingSub + windowStart := startOfDay(startsAt) + renewed.StartsAt = startsAt + renewed.ExpiresAt = expiresAt + renewed.Status = SubscriptionStatusActive + renewed.DailyWindowStart = &windowStart + renewed.WeeklyWindowStart = &windowStart + renewed.MonthlyWindowStart = &windowStart + renewed.DailyUsageUSD = 0 + renewed.WeeklyUsageUSD = 0 + renewed.MonthlyUsageUSD = 0 + renewed.Notes = appendSubscriptionNotes(existingSub.Notes, notes) + return &renewed +} + +func appendSubscriptionNotes(existingNotes, newNotes string) string { + if newNotes == "" { + return existingNotes + } + if existingNotes == "" { + return newNotes + } + return existingNotes + "\n" + newNotes +} + // createSubscription 创建新订阅(内部方法) func (s *SubscriptionService) createSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) { validityDays := input.ValidityDays @@ -945,6 +999,9 @@ func (s *SubscriptionService) calculateProgress(sub *UserSubscription, group *Gr if group.HasDailyLimit() && sub.DailyWindowStart != nil { limit := *group.DailyLimitUSD resetsAt := sub.DailyWindowStart.Add(24 * time.Hour) + if dailyResetTime := sub.DailyResetTime(); dailyResetTime != nil { + resetsAt = *dailyResetTime + } progress.Daily = &UsageWindowProgress{ LimitUSD: limit, UsedUSD: sub.DailyUsageUSD, diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go index ec547d81..6303e6e3 100644 --- a/backend/internal/service/user_subscription.go +++ b/backend/internal/service/user_subscription.go @@ -50,11 +50,25 @@ func (s *UserSubscription) IsWindowActivated() bool { return s.DailyWindowStart != nil || s.WeeklyWindowStart != nil || s.MonthlyWindowStart != nil } +func (s *UserSubscription) HasOneTimeDailyQuota() bool { + if s == nil || s.StartsAt.IsZero() || s.ExpiresAt.IsZero() { + return false + } + return !s.ExpiresAt.After(s.StartsAt.AddDate(0, 0, 1)) +} + func (s *UserSubscription) NeedsDailyReset() bool { + return s.NeedsDailyResetAt(time.Now()) +} + +func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool { if s.DailyWindowStart == nil { return false } - return time.Since(*s.DailyWindowStart) >= 24*time.Hour + if s.HasOneTimeDailyQuota() { + return false + } + return !now.Before(s.DailyWindowStart.Add(24 * time.Hour)) } func (s *UserSubscription) NeedsWeeklyReset() bool { @@ -75,6 +89,10 @@ func (s *UserSubscription) DailyResetTime() *time.Time { if s.DailyWindowStart == nil { return nil } + if s.HasOneTimeDailyQuota() { + t := s.ExpiresAt + return &t + } t := s.DailyWindowStart.Add(24 * time.Hour) return &t } diff --git a/backend/internal/service/user_subscription_daily_quota_test.go b/backend/internal/service/user_subscription_daily_quota_test.go new file mode 100644 index 00000000..3738bdd6 --- /dev/null +++ b/backend/internal/service/user_subscription_daily_quota_test.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type dailyResetTrackingUserSubRepo struct { + userSubRepoNoop + + resetDailyCalled bool +} + +func (r *dailyResetTrackingUserSubRepo) ResetDailyUsage(context.Context, int64, time.Time) error { + r.resetDailyCalled = true + return nil +} + +func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *testing.T) { + groupRepo := &subscriptionGroupRepoStub{ + group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, + } + subRepo := newSubscriptionUserSubRepoStub() + oldStart := time.Now().AddDate(0, 0, -3) + oldWindowStart := startOfDay(oldStart) + subRepo.seed(&UserSubscription{ + ID: 100, + UserID: 200, + GroupID: 1, + StartsAt: oldStart, + ExpiresAt: oldStart.AddDate(0, 0, 1), + Status: SubscriptionStatusExpired, + DailyWindowStart: &oldWindowStart, + WeeklyWindowStart: &oldWindowStart, + MonthlyWindowStart: &oldWindowStart, + DailyUsageUSD: 10, + WeeklyUsageUSD: 20, + MonthlyUsageUSD: 30, + Notes: "old", + }) + svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil) + + renewed, reused, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{ + UserID: 200, + GroupID: 1, + ValidityDays: 1, + Notes: "new", + }) + + require.NoError(t, err) + require.True(t, reused) + require.True(t, renewed.HasOneTimeDailyQuota(), "过期后重新购买 1 日卡仍应被识别为一次性日额度") + require.Equal(t, SubscriptionStatusActive, renewed.Status) + require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt") + require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1))) + require.NotNil(t, renewed.DailyWindowStart) + require.Equal(t, startOfDay(renewed.StartsAt), *renewed.DailyWindowStart) + require.Equal(t, 0.0, renewed.DailyUsageUSD) + require.Equal(t, 0.0, renewed.WeeklyUsageUSD) + require.Equal(t, 0.0, renewed.MonthlyUsageUSD) + require.Equal(t, "old\nnew", renewed.Notes) +} + +func TestUserSubscriptionNeedsDailyReset_DailyCardKeepsOneTimeQuota(t *testing.T) { + start := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) + dailyWindowStart := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) + sub := &UserSubscription{ + StartsAt: start, + ExpiresAt: start.Add(24 * time.Hour), + DailyWindowStart: &dailyWindowStart, + DailyUsageUSD: 10, + } + + require.True(t, sub.HasOneTimeDailyQuota()) + require.False(t, sub.NeedsDailyResetAt(dailyWindowStart.Add(25*time.Hour)), "日卡应作为一次性配额,跨 0 点后不再刷新日额度") +} + +func TestUserSubscriptionNeedsDailyReset_MultiDaySubscriptionStillRefreshes(t *testing.T) { + start := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) + dailyWindowStart := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) + sub := &UserSubscription{ + StartsAt: start, + ExpiresAt: start.AddDate(0, 0, 2), + DailyWindowStart: &dailyWindowStart, + } + + require.False(t, sub.HasOneTimeDailyQuota()) + require.True(t, sub.NeedsDailyResetAt(dailyWindowStart.Add(24*time.Hour)), "多日订阅仍应按 24 小时日窗口刷新") +} + +func TestUserSubscriptionDailyResetTime_DailyCardReturnsExpiry(t *testing.T) { + start := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) + dailyWindowStart := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) + expiresAt := start.Add(24 * time.Hour) + sub := &UserSubscription{ + StartsAt: start, + ExpiresAt: expiresAt, + DailyWindowStart: &dailyWindowStart, + } + + resetAt := sub.DailyResetTime() + require.NotNil(t, resetAt) + require.Equal(t, expiresAt, *resetAt, "日卡展示的日额度结束时间应为订阅过期时间") +} + +func TestCheckAndResetWindows_DailyCardDoesNotResetDailyUsage(t *testing.T) { + now := time.Now() + startsAt := now.Add(-23 * time.Hour) + dailyWindowStart := now.Add(-25 * time.Hour) + repo := &dailyResetTrackingUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + sub := &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(24 * time.Hour), + DailyUsageUSD: 10, + DailyWindowStart: &dailyWindowStart, + } + + err := svc.CheckAndResetWindows(context.Background(), sub) + + require.NoError(t, err) + require.False(t, repo.resetDailyCalled, "日卡作为一次性配额,过了 24 小时日窗口也不应重置 daily usage") + require.Equal(t, 10.0, sub.DailyUsageUSD) +} + +func TestCheckAndResetWindows_MultiDaySubscriptionStillResetsDailyUsage(t *testing.T) { + now := time.Now() + startsAt := now.Add(-48 * time.Hour) + dailyWindowStart := now.Add(-25 * time.Hour) + repo := &dailyResetTrackingUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + sub := &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 2), + DailyUsageUSD: 10, + DailyWindowStart: &dailyWindowStart, + } + + err := svc.CheckAndResetWindows(context.Background(), sub) + + require.NoError(t, err) + require.True(t, repo.resetDailyCalled, "多日订阅仍应重置过期 daily window") + require.Equal(t, 0.0, sub.DailyUsageUSD) +} + +func TestValidateAndCheckLimits_DailyCardDoesNotAllowSecondQuotaAfterMidnight(t *testing.T) { + start := time.Now().Add(-23 * time.Hour) + dailyWindowStart := time.Now().Add(-25 * time.Hour) + dailyLimit := 10.0 + sub := &UserSubscription{ + Status: SubscriptionStatusActive, + StartsAt: start, + ExpiresAt: start.Add(24 * time.Hour), + DailyWindowStart: &dailyWindowStart, + DailyUsageUSD: dailyLimit + 0.01, + } + group := &Group{ + SubscriptionType: SubscriptionTypeSubscription, + DailyLimitUSD: &dailyLimit, + } + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, group) + + require.False(t, needsMaintenance, "日卡跨过日窗口后不应触发 daily reset 维护") + require.True(t, errors.Is(err, ErrDailyLimitExceeded)) + require.Equal(t, dailyLimit+0.01, sub.DailyUsageUSD, "热路径不应清零日卡已用额度") +} From af6cdfabcc25fff2defc8718440944b7dea93a24 Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 18 May 2026 21:09:11 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(frontend):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E9=85=8D=E9=A2=9D=E6=97=B6=E9=97=B4=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/types/index.ts | 1 + frontend/src/utils/subscriptionQuota.ts | 42 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 frontend/src/utils/subscriptionQuota.ts diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ec7d0636..2a24f332 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1480,6 +1480,7 @@ export interface UserSubscription { user_id: number group_id: number status: 'active' | 'expired' | 'revoked' + starts_at: string daily_usage_usd: number weekly_usage_usd: number monthly_usage_usd: number diff --git a/frontend/src/utils/subscriptionQuota.ts b/frontend/src/utils/subscriptionQuota.ts new file mode 100644 index 00000000..719737be --- /dev/null +++ b/frontend/src/utils/subscriptionQuota.ts @@ -0,0 +1,42 @@ +import type { UserSubscription } from '@/types' + +const ONE_DAY_MS = 24 * 60 * 60 * 1000 + +export interface RemainingDurationParts { + days: number + hours: number + minutes: number +} + +export function isOneTimeDailyQuota( + subscription: Pick +): boolean { + if (!subscription.starts_at || !subscription.expires_at) return false + + const startsAt = new Date(subscription.starts_at).getTime() + const expiresAt = new Date(subscription.expires_at).getTime() + + if (!Number.isFinite(startsAt) || !Number.isFinite(expiresAt)) return false + + return expiresAt <= startsAt + ONE_DAY_MS +} + +export function getRemainingDurationParts( + targetAt: Date | string, + now: Date = new Date() +): RemainingDurationParts | null { + const targetTime = targetAt instanceof Date ? targetAt.getTime() : new Date(targetAt).getTime() + const nowTime = now.getTime() + + if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null + + const diffMs = targetTime - nowTime + if (diffMs <= 0) return null + + const totalMinutes = Math.floor(diffMs / (1000 * 60)) + const days = Math.floor(totalMinutes / (24 * 60)) + const hours = Math.floor((totalMinutes % (24 * 60)) / 60) + const minutes = totalMinutes % 60 + + return { days, hours, minutes } +} From 0b404705970e999fae6cbe0159911e23510d7939 Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 18 May 2026 21:09:11 +0800 Subject: [PATCH 3/5] =?UTF-8?q?i18n:=20=E5=A2=9E=E5=8A=A0=E6=97=A5?= =?UTF-8?q?=E5=8D=A1=E9=A2=9D=E5=BA=A6=E7=BB=93=E6=9D=9F=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/i18n/locales/en.ts | 4 ++++ frontend/src/i18n/locales/zh.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 02d044ef..82b972d6 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2670,6 +2670,9 @@ export default { resetInMinutes: 'Resets in {minutes}m', resetInHoursMinutes: 'Resets in {hours}h {minutes}m', resetInDaysHours: 'Resets in {days}d {hours}h', + quotaEndsInMinutes: 'Quota ends in {minutes}m', + quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m', + quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h', daysRemaining: 'days remaining', remainingDays: 'Remaining days', noExpiration: 'No expiration', @@ -6253,6 +6256,7 @@ export default { daysRemaining: '{days} days remaining', expiresOn: 'Expires on {date}', resetIn: 'Resets in {time}', + quotaEndsIn: 'Quota ends in {time}', windowNotActive: 'Awaiting first use', usageOf: '{used} of {limit}' }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 687c2df6..00cb6247 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2747,6 +2747,9 @@ export default { resetInMinutes: '{minutes} 分钟后重置', resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置', resetInDaysHours: '{days} 天 {hours} 小时后重置', + quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束', + quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束', + quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束', daysRemaining: '天剩余', remainingDays: '剩余天数', noExpiration: '无过期时间', @@ -6411,6 +6414,7 @@ export default { daysRemaining: '剩余 {days} 天', expiresOn: '{date} 到期', resetIn: '{time} 后重置', + quotaEndsIn: '额度将在 {time} 后结束', windowNotActive: '等待首次使用', usageOf: '已用 {used} / {limit}' }, From a66f771cb1626720f4253affc879913144fca3c4 Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 18 May 2026 21:09:11 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AB=AF=E6=97=A5=E5=8D=A1=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/views/user/SubscriptionsView.vue | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/frontend/src/views/user/SubscriptionsView.vue b/frontend/src/views/user/SubscriptionsView.vue index 154682b7..31d63d0d 100644 --- a/frontend/src/views/user/SubscriptionsView.vue +++ b/frontend/src/views/user/SubscriptionsView.vue @@ -127,11 +127,7 @@ v-if="subscription.daily_window_start" class="text-xs text-gray-500 dark:text-dark-400" > - {{ - t('userSubscriptions.resetIn', { - time: formatResetTime(subscription.daily_window_start, 24) - }) - }} + {{ formatDailyUsageWindow(subscription) }}

@@ -256,6 +252,7 @@ import AppLayout from '@/components/layout/AppLayout.vue' import Icon from '@/components/icons/Icon.vue' import { formatDateOnly } from '@/utils/format' import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors' +import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota' function platformAccentDotClass(p: string): string { switch (p) { @@ -334,30 +331,38 @@ function getExpirationClass(expiresAt: string): string { return 'text-gray-700 dark:text-gray-300' } +function formatDurationParts(parts: RemainingDurationParts): string { + if (parts.days > 0) { + return `${parts.days}d ${parts.hours}h` + } + + if (parts.hours > 0) { + return `${parts.hours}h ${parts.minutes}m` + } + + return `${parts.minutes}m` +} + +function formatDailyUsageWindow(subscription: UserSubscription): string { + if (isOneTimeDailyQuota(subscription) && subscription.expires_at) { + const parts = getRemainingDurationParts(subscription.expires_at) + if (!parts) return t('userSubscriptions.windowNotActive') + return t('userSubscriptions.quotaEndsIn', { time: formatDurationParts(parts) }) + } + + return t('userSubscriptions.resetIn', { + time: formatResetTime(subscription.daily_window_start, 24) + }) +} + function formatResetTime(windowStart: string | null, windowHours: number): string { if (!windowStart) return t('userSubscriptions.windowNotActive') const start = new Date(windowStart) const end = new Date(start.getTime() + windowHours * 60 * 60 * 1000) - const now = new Date() - const diff = end.getTime() - now.getTime() + const parts = getRemainingDurationParts(end) - if (diff <= 0) return t('userSubscriptions.windowNotActive') - - const hours = Math.floor(diff / (1000 * 60 * 60)) - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) - - if (hours > 24) { - const days = Math.floor(hours / 24) - const remainingHours = hours % 24 - return `${days}d ${remainingHours}h` - } - - if (hours > 0) { - return `${hours}h ${minutes}m` - } - - return `${minutes}m` + return parts ? formatDurationParts(parts) : t('userSubscriptions.windowNotActive') } onMounted(() => { From f1cc83e0ee7225034568f7aa8c5886b22490ce5e Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 18 May 2026 21:09:11 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(admin):=20=E4=BF=AE=E6=AD=A3=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=AB=AF=E6=97=A5=E5=8D=A1=E9=A2=9D=E5=BA=A6=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/views/admin/SubscriptionsView.vue | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index 0a76809e..6c53064d 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -246,7 +246,7 @@ d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> - {{ formatResetTime(row.daily_window_start, 'daily') }} + {{ formatDailyUsageWindow(row) }} @@ -758,6 +758,7 @@ import Select from '@/components/common/Select.vue' import GroupBadge from '@/components/common/GroupBadge.vue' import GroupOptionItem from '@/components/common/GroupOptionItem.vue' import Icon from '@/components/icons/Icon.vue' +import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota' const { t } = useI18n() const appStore = useAppStore() @@ -1313,8 +1314,41 @@ const getProgressClass = (used: number | null | undefined, limit: number | null) return 'bg-green-500' } +const formatResetDuration = (parts: RemainingDurationParts): string => { + if (parts.days > 0) { + return t('admin.subscriptions.resetInDaysHours', { days: parts.days, hours: parts.hours }) + } + + if (parts.hours > 0) { + return t('admin.subscriptions.resetInHoursMinutes', { hours: parts.hours, minutes: parts.minutes }) + } + + return t('admin.subscriptions.resetInMinutes', { minutes: parts.minutes }) +} + +const formatQuotaEndDuration = (parts: RemainingDurationParts): string => { + if (parts.days > 0) { + return t('admin.subscriptions.quotaEndsInDaysHours', { days: parts.days, hours: parts.hours }) + } + + if (parts.hours > 0) { + return t('admin.subscriptions.quotaEndsInHoursMinutes', { hours: parts.hours, minutes: parts.minutes }) + } + + return t('admin.subscriptions.quotaEndsInMinutes', { minutes: parts.minutes }) +} + +const formatDailyUsageWindow = (subscription: UserSubscription): string => { + if (isOneTimeDailyQuota(subscription) && subscription.expires_at) { + const parts = getRemainingDurationParts(subscription.expires_at) + return parts ? formatQuotaEndDuration(parts) : t('admin.subscriptions.windowNotActive') + } + + return formatResetTime(subscription.daily_window_start, 'daily') +} + // Format reset time based on window start and period type -const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'monthly'): string => { +const formatResetTime = (windowStart: string | null, period: 'daily' | 'weekly' | 'monthly'): string => { if (!windowStart) return t('admin.subscriptions.windowNotActive') const start = new Date(windowStart) @@ -1334,21 +1368,9 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont break } - const diffMs = resetTime.getTime() - now.getTime() - if (diffMs <= 0) return t('admin.subscriptions.windowNotActive') + const parts = getRemainingDurationParts(resetTime, now) - const diffSeconds = Math.floor(diffMs / 1000) - const days = Math.floor(diffSeconds / 86400) - const hours = Math.floor((diffSeconds % 86400) / 3600) - const minutes = Math.floor((diffSeconds % 3600) / 60) - - if (days > 0) { - return t('admin.subscriptions.resetInDaysHours', { days, hours }) - } else if (hours > 0) { - return t('admin.subscriptions.resetInHoursMinutes', { hours, minutes }) - } else { - return t('admin.subscriptions.resetInMinutes', { minutes }) - } + return parts ? formatResetDuration(parts) : t('admin.subscriptions.windowNotActive') } // Handle click outside to close dropdowns