mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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,使用本地时间)
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
Reference in New Issue
Block a user