diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 116346b4a6..8e1399f16d 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1454,13 +1454,14 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context, remaining := h.calculateSubscriptionRemaining(apiKey.Group, subscription) resp["remaining"] = remaining resp["subscription"] = gin.H{ - "daily_usage_usd": subscription.DailyUsageUSD, - "weekly_usage_usd": subscription.WeeklyUsageUSD, - "monthly_usage_usd": subscription.MonthlyUsageUSD, - "daily_limit_usd": apiKey.Group.DailyLimitUSD, - "weekly_limit_usd": apiKey.Group.WeeklyLimitUSD, - "monthly_limit_usd": apiKey.Group.MonthlyLimitUSD, - "expires_at": subscription.ExpiresAt, + "daily_usage_usd": subscription.DailyUsageUSD, + "weekly_usage_usd": subscription.WeeklyUsageUSD, + "monthly_usage_usd": subscription.MonthlyUsageUSD, + "daily_limit_usd": apiKey.Group.DailyLimitUSD, + "weekly_limit_usd": apiKey.Group.WeeklyLimitUSD, + "monthly_limit_usd": apiKey.Group.MonthlyLimitUSD, + "weekly_window_start": subscription.WeeklyWindowStart, + "expires_at": subscription.ExpiresAt, } } diff --git a/backend/internal/handler/gateway_handler_usage_test.go b/backend/internal/handler/gateway_handler_usage_test.go new file mode 100644 index 0000000000..b6b0e0efe6 --- /dev/null +++ b/backend/internal/handler/gateway_handler_usage_test.go @@ -0,0 +1,51 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestUsageUnrestrictedIncludesWeeklyWindowStart(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/usage", nil) + + weeklyWindowStart := time.Date(2026, time.July, 13, 0, 30, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + c.Set(string(middleware.ContextKeySubscription), &service.UserSubscription{ + WeeklyWindowStart: &weeklyWindowStart, + }) + + handler := &GatewayHandler{} + handler.usageUnrestricted( + c, + context.Background(), + &service.APIKey{Group: &service.Group{ + Name: "Weekly plan", + SubscriptionType: service.SubscriptionTypeSubscription, + }}, + middleware.AuthSubject{}, + nil, + nil, + nil, + ) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Subscription struct { + WeeklyWindowStart *time.Time `json:"weekly_window_start"` + } `json:"subscription"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.NotNil(t, response.Subscription.WeeklyWindowStart) + require.True(t, weeklyWindowStart.Equal(*response.Subscription.WeeklyWindowStart)) +} diff --git a/backend/internal/pkg/pagination/pagination.go b/backend/internal/pkg/pagination/pagination.go index ce8e74b8ce..334ba809de 100644 --- a/backend/internal/pkg/pagination/pagination.go +++ b/backend/internal/pkg/pagination/pagination.go @@ -38,7 +38,7 @@ func (p PaginationParams) Offset() int { if p.Page < 1 { p.Page = 1 } - return (p.Page - 1) * p.PageSize + return (p.Page - 1) * p.Limit() } // Limit 获取限制数 diff --git a/backend/internal/pkg/pagination/pagination_test.go b/backend/internal/pkg/pagination/pagination_test.go index 9a3b069d90..9704449e92 100644 --- a/backend/internal/pkg/pagination/pagination_test.go +++ b/backend/internal/pkg/pagination/pagination_test.go @@ -69,3 +69,30 @@ func TestPaginationParamsLimit(t *testing.T) { }) } } + +func TestPaginationParamsOffsetUsesNormalizedLimit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + page int + pageSize int + want int + }{ + {name: "invalid page uses first page", page: 0, pageSize: 50, want: 0}, + {name: "zero page size uses default", page: 2, pageSize: 0, want: 20}, + {name: "negative page size uses default", page: 2, pageSize: -1, want: 20}, + {name: "normal values", page: 3, pageSize: 50, want: 100}, + {name: "page size beyond max is clamped", page: 2, pageSize: 1500, want: 1000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + params := PaginationParams{Page: tt.page, PageSize: tt.pageSize} + if got := params.Offset(); got != tt.want { + t.Fatalf("Offset() for Page=%d, PageSize=%d = %d, want %d", tt.page, tt.pageSize, got, tt.want) + } + }) + } +} diff --git a/frontend/src/utils/__tests__/formatDateLocalInput.spec.ts b/frontend/src/utils/__tests__/formatDateLocalInput.spec.ts new file mode 100644 index 0000000000..76b7af851f --- /dev/null +++ b/frontend/src/utils/__tests__/formatDateLocalInput.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' + +import { formatDateLocalInput } from '../format' + +describe('formatDateLocalInput', () => { + it('formats the calendar date in local time', () => { + const localDate = new Date('2026-07-12T16:30:00Z') + vi.spyOn(localDate, 'getFullYear').mockReturnValue(2026) + vi.spyOn(localDate, 'getMonth').mockReturnValue(6) + vi.spyOn(localDate, 'getDate').mockReturnValue(13) + + expect(formatDateLocalInput(localDate)).toBe('2026-07-13') + }) + + it('returns an empty string for an invalid date', () => { + expect(formatDateLocalInput(new Date('invalid'))).toBe('') + }) +}) diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 6f13a065af..481fe397ef 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -149,6 +149,17 @@ export function formatDateTime( return formatDate(date, options, localeOverride) } +/** + * 格式化为 date 控件值(YYYY-MM-DD,使用本地时间) + */ +export function formatDateLocalInput(date: Date): string { + if (isNaN(date.getTime())) return '' + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + /** * 格式化为 datetime-local 控件值(YYYY-MM-DDTHH:mm,使用本地时间) */ diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue index 2cbecbcb66..32581a14ef 100644 --- a/frontend/src/views/KeyUsageView.vue +++ b/frontend/src/views/KeyUsageView.vue @@ -423,6 +423,7 @@ import { useAppStore } from '@/stores' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import Icon from '@/components/icons/Icon.vue' import { buildGatewayUrl } from '@/api/client' +import { formatDateLocalInput } from '@/utils/format' import { sanitizeUrl } from '@/utils/url' const { t, locale } = useI18n() @@ -490,7 +491,6 @@ function setDateRange(key: DateRangeKey) { function getDateParams(): string { const now = new Date() - const fmt = (d: Date) => d.toISOString().split('T')[0] const params = new URLSearchParams() if (currentRange.value === 'custom') { @@ -499,13 +499,13 @@ function getDateParams(): string { params.set('end_date', customEndDate.value) } } else { - const end = fmt(now) + const end = formatDateLocalInput(now) let start: string switch (currentRange.value) { case 'today': start = end; break - case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break - case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break - default: start = fmt(new Date(now.getTime() - 30 * 86400000)) + case '7d': start = formatDateLocalInput(new Date(now.getTime() - 7 * 86400000)); break + case '30d': start = formatDateLocalInput(new Date(now.getTime() - 30 * 86400000)); break + default: start = formatDateLocalInput(new Date(now.getTime() - 30 * 86400000)) } params.set('start_date', start) params.set('end_date', end) diff --git a/frontend/src/views/__tests__/KeyUsageView.spec.ts b/frontend/src/views/__tests__/KeyUsageView.spec.ts index c1373bc30f..224485fd49 100644 --- a/frontend/src/views/__tests__/KeyUsageView.spec.ts +++ b/frontend/src/views/__tests__/KeyUsageView.spec.ts @@ -162,6 +162,7 @@ describe('KeyUsageView daily detail', () => { }) afterEach(() => { + vi.useRealTimers() vi.unstubAllGlobals() }) @@ -205,4 +206,29 @@ describe('KeyUsageView daily detail', () => { wrapper.unmount() }) + + it('queries the current local calendar date near midnight', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 13, 0, 30)) + + const wrapper = mount(KeyUsageView, { + global: { + stubs: { + RouterLink: { template: '' }, + LocaleSwitcher: true, + Icon: true, + }, + }, + }) + + await wrapper.find('input').setValue('sk-test-key') + await wrapper.find('input').trigger('keydown.enter') + await flushPromises() + + const requestUrl = String(vi.mocked(fetch).mock.calls[0][0]) + expect(requestUrl).toContain('start_date=2026-07-13') + expect(requestUrl).toContain('end_date=2026-07-13') + + wrapper.unmount() + }) }) diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue index 5609ba7abb..815e905182 100644 --- a/frontend/src/views/user/DashboardView.vue +++ b/frontend/src/views/user/DashboardView.vue @@ -21,14 +21,14 @@ import UserDashboardStats from '@/components/user/dashboard/UserDashboardStats.v import UserDashboardRecentUsage from '@/components/user/dashboard/UserDashboardRecentUsage.vue'; import UserDashboardQuickActions from '@/components/user/dashboard/UserDashboardQuickActions.vue' import type { UsageLog, TrendDataPoint, ModelStat, PlatformQuotaItem } from '@/types' import { getMyPlatformQuotas } from '@/api/user' +import { formatDateLocalInput } from '@/utils/format' const authStore = useAuthStore(); const user = computed(() => authStore.user) const stats = ref(null); const loading = ref(false); const loadingUsage = ref(false); const loadingCharts = ref(false) const trendData = ref([]); const modelStats = ref([]); const recentUsage = ref([]) const platformQuotas = ref(null) -const formatLD = (d: Date) => d.toISOString().split('T')[0] -const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatLD(new Date())); const granularity = ref('day') +const startDate = ref(formatDateLocalInput(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatDateLocalInput(new Date())); const granularity = ref('day') const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch (error) { console.error('Failed to load dashboard stats:', error) } finally { loading.value = false } } const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch (error) { console.error('Failed to load charts:', error) } finally { loadingCharts.value = false } }