From 806f402bba5b7407adb964de42e9ff631de3219b Mon Sep 17 00:00:00 2001 From: longgexx Date: Wed, 14 Jan 2026 21:46:39 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(admin):=20=E4=BF=AE=E5=A4=8D=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E8=AE=B0=E5=BD=95=E9=A1=B5=E9=9D=A2=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?=E5=9B=BE=E7=AD=9B=E9=80=89=E8=81=94=E5=8A=A8=E5=92=8C=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E9=80=89=E6=8B=A9=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复两个问题: 1. Token使用趋势图和模型分布图未响应筛选条件 2. 上午时段选择今天刷新后日期回退到前一天 前端修改: - 更新 dashboard API 类型定义,添加 model、account_id、group_id、stream 参数支持 - 修改 UsageView 趋势图加载逻辑,传递所有筛选参数到后端 - 修复日期格式化函数,使用本地时区避免 UTC 转换导致的日期偏移 后端修改: - Handler 层:接收并解析所有筛选参数(model、account_id、group_id、stream) - Service 层:传递完整的筛选参数到 Repository 层 - Repository 层:SQL 查询动态添加所有过滤条件 - 更新接口定义和所有调用点以保持一致性 影响范围: - /admin/dashboard/trend 端点现支持完整筛选 - /admin/dashboard/models 端点现支持完整筛选 - 用户在后台使用记录页面选择任意筛选条件时,趋势图和模型分布图会实时响应 - 日期选择器在任何时区下都能正确保持今天的选择 --- .../handler/admin/dashboard_handler.go | 50 ++++++++++++++++--- backend/internal/repository/usage_log_repo.go | 34 +++++++++++-- .../internal/service/account_usage_service.go | 8 +-- backend/internal/service/dashboard_service.go | 8 +-- backend/internal/service/ratelimit_service.go | 4 +- frontend/src/api/admin/dashboard.ts | 8 +++ frontend/src/views/admin/UsageView.vue | 14 ++++-- 7 files changed, 101 insertions(+), 25 deletions(-) diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 9b675974..3f07403d 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -186,13 +186,16 @@ func (h *DashboardHandler) GetRealtimeMetrics(c *gin.Context) { // GetUsageTrend handles getting usage trend data // GET /api/v1/admin/dashboard/trend -// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id +// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id, model, account_id, group_id, stream func (h *DashboardHandler) GetUsageTrend(c *gin.Context) { startTime, endTime := parseTimeRange(c) granularity := c.DefaultQuery("granularity", "day") // Parse optional filter params - var userID, apiKeyID int64 + var userID, apiKeyID, accountID, groupID int64 + var model string + var stream *bool + if userIDStr := c.Query("user_id"); userIDStr != "" { if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil { userID = id @@ -203,8 +206,26 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) { apiKeyID = id } } + if accountIDStr := c.Query("account_id"); accountIDStr != "" { + if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil { + accountID = id + } + } + if groupIDStr := c.Query("group_id"); groupIDStr != "" { + if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil { + groupID = id + } + } + if modelStr := c.Query("model"); modelStr != "" { + model = modelStr + } + if streamStr := c.Query("stream"); streamStr != "" { + if streamVal, err := strconv.ParseBool(streamStr); err == nil { + stream = &streamVal + } + } - trend, err := h.dashboardService.GetUsageTrendWithFilters(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID) + trend, err := h.dashboardService.GetUsageTrendWithFilters(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, stream) if err != nil { response.Error(c, 500, "Failed to get usage trend") return @@ -220,12 +241,14 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) { // GetModelStats handles getting model usage statistics // GET /api/v1/admin/dashboard/models -// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id +// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, stream func (h *DashboardHandler) GetModelStats(c *gin.Context) { startTime, endTime := parseTimeRange(c) // Parse optional filter params - var userID, apiKeyID int64 + var userID, apiKeyID, accountID, groupID int64 + var stream *bool + if userIDStr := c.Query("user_id"); userIDStr != "" { if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil { userID = id @@ -236,8 +259,23 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { apiKeyID = id } } + if accountIDStr := c.Query("account_id"); accountIDStr != "" { + if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil { + accountID = id + } + } + if groupIDStr := c.Query("group_id"); groupIDStr != "" { + if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil { + groupID = id + } + } + if streamStr := c.Query("stream"); streamStr != "" { + if streamVal, err := strconv.ParseBool(streamStr); err == nil { + stream = &streamVal + } + } - stats, err := h.dashboardService.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID) + stats, err := h.dashboardService.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, stream) if err != nil { response.Error(c, 500, "Failed to get model statistics") return diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index e483f89f..5141bd35 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -1400,8 +1400,8 @@ func (r *usageLogRepository) GetBatchAPIKeyUsageStats(ctx context.Context, apiKe return result, nil } -// GetUsageTrendWithFilters returns usage trend data with optional user/api_key filters -func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) (results []TrendDataPoint, err error) { +// GetUsageTrendWithFilters returns usage trend data with optional filters +func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, stream *bool) (results []TrendDataPoint, err error) { dateFormat := "YYYY-MM-DD" if granularity == "hour" { dateFormat = "YYYY-MM-DD HH24:00" @@ -1430,6 +1430,22 @@ func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) args = append(args, apiKeyID) } + if accountID > 0 { + query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) + args = append(args, accountID) + } + if groupID > 0 { + query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) + args = append(args, groupID) + } + if model != "" { + query += fmt.Sprintf(" AND model = $%d", len(args)+1) + args = append(args, model) + } + if stream != nil { + query += fmt.Sprintf(" AND stream = $%d", len(args)+1) + args = append(args, *stream) + } query += " GROUP BY date ORDER BY date ASC" rows, err := r.sql.QueryContext(ctx, query, args...) @@ -1452,8 +1468,8 @@ func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start return results, nil } -// GetModelStatsWithFilters returns model statistics with optional user/api_key filters -func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID int64) (results []ModelStat, err error) { +// GetModelStatsWithFilters returns model statistics with optional filters +func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, stream *bool) (results []ModelStat, err error) { query := ` SELECT model, @@ -1480,6 +1496,14 @@ func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, start query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) args = append(args, accountID) } + if groupID > 0 { + query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) + args = append(args, groupID) + } + if stream != nil { + query += fmt.Sprintf(" AND stream = $%d", len(args)+1) + args = append(args, *stream) + } query += " GROUP BY model ORDER BY total_tokens DESC" rows, err := r.sql.QueryContext(ctx, query, args...) @@ -1767,7 +1791,7 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID } } - models, err := r.GetModelStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID) + models, err := r.GetModelStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID, 0, nil) if err != nil { models = []ModelStat{} } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index f1ee43d2..2bd4f1cb 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -32,8 +32,8 @@ type UsageLogRepository interface { // Admin dashboard stats GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) - GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]usagestats.TrendDataPoint, error) - GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID int64) ([]usagestats.ModelStat, error) + GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, stream *bool) ([]usagestats.TrendDataPoint, error) + GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, stream *bool) ([]usagestats.ModelStat, error) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) @@ -266,7 +266,7 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou } dayStart := geminiDailyWindowStart(now) - stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, dayStart, now, 0, 0, account.ID) + stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, dayStart, now, 0, 0, account.ID, 0, nil) if err != nil { return nil, fmt.Errorf("get gemini usage stats failed: %w", err) } @@ -288,7 +288,7 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou // Minute window (RPM) - fixed-window approximation: current minute [truncate(now), truncate(now)+1m) minuteStart := now.Truncate(time.Minute) minuteResetAt := minuteStart.Add(time.Minute) - minuteStats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, minuteStart, now, 0, 0, account.ID) + minuteStats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, minuteStart, now, 0, 0, account.ID, 0, nil) if err != nil { return nil, fmt.Errorf("get gemini minute usage stats failed: %w", err) } diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 9bc56c54..a9811919 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -124,16 +124,16 @@ func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.D return stats, nil } -func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]usagestats.TrendDataPoint, error) { - trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID) +func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, stream *bool) ([]usagestats.TrendDataPoint, error) { + trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, stream) if err != nil { return nil, fmt.Errorf("get usage trend with filters: %w", err) } return trend, nil } -func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID int64) ([]usagestats.ModelStat, error) { - stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, 0) +func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, stream *bool) ([]usagestats.ModelStat, error) { + stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, stream) if err != nil { return nil, fmt.Errorf("get model stats with filters: %w", err) } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 62b0c6b8..a2281f12 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -163,7 +163,7 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, start := geminiDailyWindowStart(now) totals, ok := s.getGeminiUsageTotals(account.ID, start, now) if !ok { - stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID) + stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID, 0, nil) if err != nil { return true, err } @@ -210,7 +210,7 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, if limit > 0 { start := now.Truncate(time.Minute) - stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID) + stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID, 0, nil) if err != nil { return true, err } diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 83e56c0e..9b338788 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -46,6 +46,10 @@ export interface TrendParams { granularity?: 'day' | 'hour' user_id?: number api_key_id?: number + model?: string + account_id?: number + group_id?: number + stream?: boolean } export interface TrendResponse { @@ -70,6 +74,10 @@ export interface ModelStatsParams { end_date?: string user_id?: number api_key_id?: number + model?: string + account_id?: number + group_id?: number + stream?: boolean } export interface ModelStatsResponse { diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index fbde13fd..e1302da3 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -44,8 +44,14 @@ let abortController: AbortController | null = null; let exportAbortController: A const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' }) const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }]) -const formatLD = (d: Date) => d.toISOString().split('T')[0] -const now = new Date(); const weekAgo = new Date(Date.now() - 6 * 86400000) +// Use local timezone to avoid UTC timezone issues +const formatLD = (d: Date) => { + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} +const now = new Date(); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6) const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now)) const filters = ref({ user_id: undefined, model: undefined, group_id: undefined, start_date: startDate.value, end_date: endDate.value }) const pagination = reactive({ page: 1, page_size: 20, total: 0 }) @@ -61,8 +67,8 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi const loadChartData = async () => { chartsLoading.value = true try { - const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id } - const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id })]) + const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream } + const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream })]) trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || [] } catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false } } From 5efeabb0c6ebd06e38e49af8d328162311171bce Mon Sep 17 00:00:00 2001 From: longgexx Date: Wed, 14 Jan 2026 22:02:56 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(admin):=20=E4=BF=AE=E5=A4=8D=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E8=AE=B0=E5=BD=95=E9=A1=B5=E9=9D=A2=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?=E5=9B=BE=E7=AD=9B=E9=80=89=E8=81=94=E5=8A=A8=E5=92=8C=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E9=80=89=E6=8B=A9=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复两个问题: 1. Token使用趋势图和模型分布图未响应筛选条件 2. 上午时段选择今天刷新后日期回退到前一天 前端修改: - 更新 dashboard API 类型定义,添加 model、account_id、group_id、stream 参数支持 - 修改 UsageView 趋势图加载逻辑,传递所有筛选参数到后端 - 修复日期格式化函数,使用本地时区避免 UTC 转换导致的日期偏移 后端修改: - Handler 层:接收并解析所有筛选参数(model、account_id、group_id、stream) - Service 层:传递完整的筛选参数到 Repository 层 - Repository 层:SQL 查询动态添加所有过滤条件 - 更新接口定义和测试 mock 以保持一致性 影响范围: - /admin/dashboard/trend 端点现支持完整筛选 - /admin/dashboard/models 端点现支持完整筛选 - 用户在后台使用记录页面选择任意筛选条件时,趋势图和模型分布图会实时响应 - 日期选择器在任何时区下都能正确保持今天的选择 --- backend/internal/server/api_contract_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d96732bd..f81a6089 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1229,11 +1229,11 @@ func (r *stubUsageLogRepo) GetDashboardStats(ctx context.Context) (*usagestats.D return nil, errors.New("not implemented") } -func (r *stubUsageLogRepo) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]usagestats.TrendDataPoint, error) { +func (r *stubUsageLogRepo) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, stream *bool) ([]usagestats.TrendDataPoint, error) { return nil, errors.New("not implemented") } -func (r *stubUsageLogRepo) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID int64) ([]usagestats.ModelStat, error) { +func (r *stubUsageLogRepo) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, stream *bool) ([]usagestats.ModelStat, error) { return nil, errors.New("not implemented") } From 6fabddcb0b5f244de2edc1e29b6b68ccc858f3c7 Mon Sep 17 00:00:00 2001 From: longgexx Date: Wed, 14 Jan 2026 22:29:14 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(test):=20=E6=9B=B4=E6=96=B0=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95=E4=BB=A5=E5=8C=B9=E9=85=8D=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E7=AD=9B=E9=80=89=E5=8F=82=E6=95=B0=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新 usage_log_repo_integration_test.go 中的测试用例, 使其与 GetUsageTrendWithFilters 和 GetModelStatsWithFilters 方法的新签名保持一致。 --- .../repository/usage_log_repo_integration_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index 3f90e49e..c9c2678b 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -872,17 +872,17 @@ func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters() { endTime := base.Add(48 * time.Hour) // Test with user filter - trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, 0) + trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, 0, 0, 0, "", nil) s.Require().NoError(err, "GetUsageTrendWithFilters user filter") s.Require().Len(trend, 2) // Test with apiKey filter - trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", 0, apiKey.ID) + trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", 0, apiKey.ID, 0, 0, "", nil) s.Require().NoError(err, "GetUsageTrendWithFilters apiKey filter") s.Require().Len(trend, 2) // Test with both filters - trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, apiKey.ID) + trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, apiKey.ID, 0, 0, "", nil) s.Require().NoError(err, "GetUsageTrendWithFilters both filters") s.Require().Len(trend, 2) } @@ -899,7 +899,7 @@ func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters_HourlyGranularity() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(3 * time.Hour) - trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "hour", user.ID, 0) + trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "hour", user.ID, 0, 0, 0, "", nil) s.Require().NoError(err, "GetUsageTrendWithFilters hourly") s.Require().Len(trend, 2) } @@ -945,17 +945,17 @@ func (s *UsageLogRepoSuite) TestGetModelStatsWithFilters() { endTime := base.Add(2 * time.Hour) // Test with user filter - stats, err := s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, user.ID, 0, 0) + stats, err := s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, user.ID, 0, 0, 0, nil) s.Require().NoError(err, "GetModelStatsWithFilters user filter") s.Require().Len(stats, 2) // Test with apiKey filter - stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, apiKey.ID, 0) + stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, apiKey.ID, 0, 0, nil) s.Require().NoError(err, "GetModelStatsWithFilters apiKey filter") s.Require().Len(stats, 2) // Test with account filter - stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, 0, account.ID) + stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, 0, account.ID, 0, nil) s.Require().NoError(err, "GetModelStatsWithFilters account filter") s.Require().Len(stats, 2) } From 5aaaffe4d1654592208a8c99736a47cc7bd9a382 Mon Sep 17 00:00:00 2001 From: longgexx Date: Thu, 15 Jan 2026 09:12:16 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(dashboard):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BB=AA=E8=A1=A8=E7=9B=98=E4=BB=8A=E6=97=A5=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E4=BD=BF=E7=94=A8UTC=E6=97=B6=E5=8C=BA=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将仪表盘统计中的"今日"时间范围从UTC时区改为服务器配置时区, 使其与使用记录页面保持一致。 修改内容: - GetDashboardStats: 使用 timezone.Now() 和 timezone.Today() - GetDashboardStatsWithRange: 同上 影响的统计项: - 今日请求 (TodayRequests) - 今日 Token (TodayTokens) - 今日费用 (TodayCost/TodayActualCost) - 今日新用户 (TodayNewUsers) - 今日活跃用户 (ActiveUsers) --- backend/internal/repository/usage_log_repo.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 5141bd35..911bf621 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -270,13 +270,13 @@ type DashboardStats = usagestats.DashboardStats func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { stats := &DashboardStats{} - now := time.Now().UTC() - todayUTC := truncateToDayUTC(now) + now := timezone.Now() + todayStart := timezone.Today() - if err := r.fillDashboardEntityStats(ctx, stats, todayUTC, now); err != nil { + if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil { return nil, err } - if err := r.fillDashboardUsageStatsAggregated(ctx, stats, todayUTC, now); err != nil { + if err := r.fillDashboardUsageStatsAggregated(ctx, stats, todayStart, now); err != nil { return nil, err } @@ -298,13 +298,13 @@ func (r *usageLogRepository) GetDashboardStatsWithRange(ctx context.Context, sta } stats := &DashboardStats{} - now := time.Now().UTC() - todayUTC := truncateToDayUTC(now) + now := timezone.Now() + todayStart := timezone.Today() - if err := r.fillDashboardEntityStats(ctx, stats, todayUTC, now); err != nil { + if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil { return nil, err } - if err := r.fillDashboardUsageStatsFromUsageLogs(ctx, stats, startUTC, endUTC, todayUTC, now); err != nil { + if err := r.fillDashboardUsageStatsFromUsageLogs(ctx, stats, startUTC, endUTC, todayStart, now); err != nil { return nil, err } From 850183c2697f68c1261e659dfb9b81984f05d0a7 Mon Sep 17 00:00:00 2001 From: longgexx Date: Thu, 15 Jan 2026 11:22:13 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(dashboard):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=A2=84=E8=81=9A=E5=90=88=E8=A1=A8=E4=BD=BF=E7=94=A8UTC?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E5=AF=BC=E8=87=B4=E4=BB=8A=E6=97=A5=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E4=B8=8D=E5=87=86=E7=A1=AE=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 dashboard_aggregation_repo.go 和 usage_log_repo.go 中的时区处理 从 UTC 改为使用服务器配置时区(默认 Asia/Shanghai),确保"今日" 统计数据与用户预期一致。 Co-Authored-By: Claude Opus 4.5 --- .../repository/dashboard_aggregation_repo.go | 47 ++++++++++--------- backend/internal/repository/usage_log_repo.go | 2 +- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/backend/internal/repository/dashboard_aggregation_repo.go b/backend/internal/repository/dashboard_aggregation_repo.go index d238e320..3543e061 100644 --- a/backend/internal/repository/dashboard_aggregation_repo.go +++ b/backend/internal/repository/dashboard_aggregation_repo.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/lib/pq" ) @@ -41,21 +42,22 @@ func isPostgresDriver(db *sql.DB) bool { } func (r *dashboardAggregationRepository) AggregateRange(ctx context.Context, start, end time.Time) error { - startUTC := start.UTC() - endUTC := end.UTC() - if !endUTC.After(startUTC) { + loc := timezone.Location() + startLocal := start.In(loc) + endLocal := end.In(loc) + if !endLocal.After(startLocal) { return nil } - hourStart := startUTC.Truncate(time.Hour) - hourEnd := endUTC.Truncate(time.Hour) - if endUTC.After(hourEnd) { + hourStart := startLocal.Truncate(time.Hour) + hourEnd := endLocal.Truncate(time.Hour) + if endLocal.After(hourEnd) { hourEnd = hourEnd.Add(time.Hour) } - dayStart := truncateToDayUTC(startUTC) - dayEnd := truncateToDayUTC(endUTC) - if endUTC.After(dayEnd) { + dayStart := truncateToDay(startLocal) + dayEnd := truncateToDay(endLocal) + if endLocal.After(dayEnd) { dayEnd = dayEnd.Add(24 * time.Hour) } @@ -146,38 +148,41 @@ func (r *dashboardAggregationRepository) EnsureUsageLogsPartitions(ctx context.C } func (r *dashboardAggregationRepository) insertHourlyActiveUsers(ctx context.Context, start, end time.Time) error { + tzName := timezone.Name() query := ` INSERT INTO usage_dashboard_hourly_users (bucket_start, user_id) SELECT DISTINCT - date_trunc('hour', created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket_start, + date_trunc('hour', created_at AT TIME ZONE $3) AT TIME ZONE $3 AS bucket_start, user_id FROM usage_logs WHERE created_at >= $1 AND created_at < $2 ON CONFLICT DO NOTHING ` - _, err := r.sql.ExecContext(ctx, query, start.UTC(), end.UTC()) + _, err := r.sql.ExecContext(ctx, query, start, end, tzName) return err } func (r *dashboardAggregationRepository) insertDailyActiveUsers(ctx context.Context, start, end time.Time) error { + tzName := timezone.Name() query := ` INSERT INTO usage_dashboard_daily_users (bucket_date, user_id) SELECT DISTINCT - (bucket_start AT TIME ZONE 'UTC')::date AS bucket_date, + (bucket_start AT TIME ZONE $3)::date AS bucket_date, user_id FROM usage_dashboard_hourly_users WHERE bucket_start >= $1 AND bucket_start < $2 ON CONFLICT DO NOTHING ` - _, err := r.sql.ExecContext(ctx, query, start.UTC(), end.UTC()) + _, err := r.sql.ExecContext(ctx, query, start, end, tzName) return err } func (r *dashboardAggregationRepository) upsertHourlyAggregates(ctx context.Context, start, end time.Time) error { + tzName := timezone.Name() query := ` WITH hourly AS ( SELECT - date_trunc('hour', created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket_start, + date_trunc('hour', created_at AT TIME ZONE $3) AT TIME ZONE $3 AS bucket_start, COUNT(*) AS total_requests, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, @@ -236,15 +241,16 @@ func (r *dashboardAggregationRepository) upsertHourlyAggregates(ctx context.Cont active_users = EXCLUDED.active_users, computed_at = EXCLUDED.computed_at ` - _, err := r.sql.ExecContext(ctx, query, start.UTC(), end.UTC()) + _, err := r.sql.ExecContext(ctx, query, start, end, tzName) return err } func (r *dashboardAggregationRepository) upsertDailyAggregates(ctx context.Context, start, end time.Time) error { + tzName := timezone.Name() query := ` WITH daily AS ( SELECT - (bucket_start AT TIME ZONE 'UTC')::date AS bucket_date, + (bucket_start AT TIME ZONE $5)::date AS bucket_date, COALESCE(SUM(total_requests), 0) AS total_requests, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, @@ -255,7 +261,7 @@ func (r *dashboardAggregationRepository) upsertDailyAggregates(ctx context.Conte COALESCE(SUM(total_duration_ms), 0) AS total_duration_ms FROM usage_dashboard_hourly WHERE bucket_start >= $1 AND bucket_start < $2 - GROUP BY (bucket_start AT TIME ZONE 'UTC')::date + GROUP BY (bucket_start AT TIME ZONE $5)::date ), user_counts AS ( SELECT bucket_date, COUNT(*) AS active_users @@ -303,7 +309,7 @@ func (r *dashboardAggregationRepository) upsertDailyAggregates(ctx context.Conte active_users = EXCLUDED.active_users, computed_at = EXCLUDED.computed_at ` - _, err := r.sql.ExecContext(ctx, query, start.UTC(), end.UTC(), start.UTC(), end.UTC()) + _, err := r.sql.ExecContext(ctx, query, start, end, start, end, tzName) return err } @@ -376,9 +382,8 @@ func (r *dashboardAggregationRepository) createUsageLogsPartition(ctx context.Co return err } -func truncateToDayUTC(t time.Time) time.Time { - t = t.UTC() - return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +func truncateToDay(t time.Time) time.Time { + return timezone.StartOfDay(t) } func truncateToMonthUTC(t time.Time) time.Time { diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 911bf621..338a3a3b 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -455,7 +455,7 @@ func (r *usageLogRepository) fillDashboardUsageStatsAggregated(ctx context.Conte FROM usage_dashboard_hourly WHERE bucket_start = $1 ` - hourStart := now.UTC().Truncate(time.Hour) + hourStart := now.In(timezone.Location()).Truncate(time.Hour) if err := scanSingleRow(ctx, r.sql, hourlyActiveQuery, []any{hourStart}, &stats.HourlyActiveUsers); err != nil { if err != sql.ErrNoRows { return err From 82743704e428fb5638174ca09fba87c029853210 Mon Sep 17 00:00:00 2001 From: longgexx Date: Thu, 15 Jan 2026 11:42:35 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(test):=20=E6=B7=BB=E5=8A=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=BE=85=E5=8A=A9=E5=87=BD=E6=95=B0=20truncateToDayUT?= =?UTF-8?q?C=20=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 usage_log_repo_integration_test.go 中添加本地的 truncateToDayUTC 辅助函数,修复因主代码重命名该函数导致的测试编译错误。 Co-Authored-By: Claude Opus 4.5 --- .../internal/repository/usage_log_repo_integration_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index c9c2678b..3b48fbb9 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -36,6 +36,12 @@ func TestUsageLogRepoSuite(t *testing.T) { suite.Run(t, new(UsageLogRepoSuite)) } +// truncateToDayUTC 截断到 UTC 日期边界(测试辅助函数) +func truncateToDayUTC(t time.Time) time.Time { + t = t.UTC() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +} + func (s *UsageLogRepoSuite) createUsageLog(user *service.User, apiKey *service.APIKey, account *service.Account, inputTokens, outputTokens int, cost float64, createdAt time.Time) *service.UsageLog { log := &service.UsageLog{ UserID: user.ID,