Merge pull request #4134 from yan9651688/fix/usage-window-pagination-local-dates

fix: align usage windows, pagination offsets, and local dates
This commit is contained in:
Wesley Liddick
2026-07-13 14:13:34 +08:00
committed by GitHub
9 changed files with 149 additions and 15 deletions
+8 -7
View File
@@ -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,
}
}
@@ -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))
}
@@ -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 获取限制数
@@ -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)
}
})
}
}
@@ -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('')
})
})
+11
View File
@@ -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,使用本地时间)
*/
+5 -5
View File
@@ -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)
@@ -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: '<a><slot /></a>' },
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()
})
})
+2 -2
View File
@@ -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<UserStatsType | null>(null); const loading = ref(false); const loadingUsage = ref(false); const loadingCharts = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const recentUsage = ref<UsageLog[]>([])
const platformQuotas = ref<PlatformQuotaItem[] | null>(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 } }