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