diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 93746844..361c6ed7 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -67,9 +67,9 @@ type DashboardStats struct { TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"` TodayCacheReadTokens int64 `json:"today_cache_read_tokens"` TodayTokens int64 `json:"today_tokens"` - TodayCost float64 `json:"today_cost"` // 今日标准计费 - TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除 - TodayAccountCost float64 `json:"today_account_cost"` // 今日账号成本 + TodayCost float64 `json:"today_cost"` // 今日标准计费 + TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除 + TodayAccountCost float64 `json:"today_account_cost"` // 今日账号成本 // 系统运行统计 AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间 diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index 0383f3bc..ed3050d8 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -753,8 +753,11 @@ func (s *UsageLogRepoSuite) TestDashboardStats_TodayTotalsAndPerformance() { s.Require().Equal(baseStats.TotalTokens+int64(51), stats.TotalTokens, "TotalTokens mismatch") s.Require().Equal(baseStats.TotalCost+2.3, stats.TotalCost, "TotalCost mismatch") s.Require().Equal(baseStats.TotalActualCost+2.0, stats.TotalActualCost, "TotalActualCost mismatch") + // account_cost falls back to total_cost when account_stats_cost is NULL + s.Require().Equal(baseStats.TotalAccountCost+2.3, stats.TotalAccountCost, "TotalAccountCost mismatch") s.Require().GreaterOrEqual(stats.TodayRequests, int64(1), "expected TodayRequests >= 1") s.Require().GreaterOrEqual(stats.TodayCost, 0.0, "expected TodayCost >= 0") + s.Require().GreaterOrEqual(stats.TodayAccountCost, 0.0, "expected TodayAccountCost >= 0") wantRpm, wantTpm, err := s.repo.getPerformanceStats(s.ctx, 0) s.Require().NoError(err, "getPerformanceStats") @@ -833,6 +836,8 @@ func (s *UsageLogRepoSuite) TestDashboardStatsWithRange_Fallback() { s.Require().Equal(int64(45), stats.TotalTokens) s.Require().Equal(1.5, stats.TotalCost) s.Require().Equal(1.4, stats.TotalActualCost) + // account_cost = COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1) = total_cost + s.Require().Equal(1.5, stats.TotalAccountCost) s.Require().InEpsilon(150.0, stats.AverageDurationMs, 0.0001) } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index acdd6e62..a5ff4bc1 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -301,7 +301,7 @@ func TestUsageLogRepositoryGetModelStatsWithFiltersRequestTypePriority(t *testin mock.ExpectQuery("AND \\(request_type = \\$3 OR \\(request_type = 0 AND openai_ws_mode = TRUE\\)\\)"). WithArgs(start, end, requestType). - WillReturnRows(sqlmock.NewRows([]string{"model", "requests", "input_tokens", "output_tokens", "cache_creation_tokens", "cache_read_tokens", "total_tokens", "cost", "actual_cost"})) + WillReturnRows(sqlmock.NewRows([]string{"model", "requests", "input_tokens", "output_tokens", "cache_creation_tokens", "cache_read_tokens", "total_tokens", "cost", "actual_cost", "account_cost"})) stats, err := repo.GetModelStatsWithFilters(context.Background(), start, end, 0, 0, 0, 0, &requestType, &stream, nil) require.NoError(t, err) @@ -346,6 +346,93 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T) require.NoError(t, err) require.Equal(t, int64(1), stats.TotalRequests) require.Equal(t, int64(9), stats.TotalTokens) + require.NotNil(t, stats.TotalAccountCost, "TotalAccountCost should always be returned") + require.Equal(t, 1.2, *stats.TotalAccountCost) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUsageLogRepositoryGetModelStatsAccountCostColumn(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + + start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + + mock.ExpectQuery("FROM usage_logs"). + WithArgs(start, end). + WillReturnRows(sqlmock.NewRows([]string{ + "model", "requests", "input_tokens", "output_tokens", + "cache_creation_tokens", "cache_read_tokens", "total_tokens", + "cost", "actual_cost", "account_cost", + }). + AddRow("claude-opus-4-6", int64(10), int64(100), int64(200), int64(5), int64(3), int64(308), 2.5, 2.0, 1.8). + AddRow("claude-sonnet-4-6", int64(5), int64(50), int64(100), int64(0), int64(0), int64(150), 1.0, 0.8, 0.7)) + + results, err := repo.GetModelStatsWithFilters(context.Background(), start, end, 0, 0, 0, 0, nil, nil, nil) + require.NoError(t, err) + require.Len(t, results, 2) + require.Equal(t, "claude-opus-4-6", results[0].Model) + require.Equal(t, 2.5, results[0].Cost) + require.Equal(t, 2.0, results[0].ActualCost) + require.Equal(t, 1.8, results[0].AccountCost) + require.Equal(t, "claude-sonnet-4-6", results[1].Model) + require.Equal(t, 0.7, results[1].AccountCost) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUsageLogRepositoryGetGroupStatsAccountCostColumn(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + + start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + + mock.ExpectQuery("FROM usage_logs"). + WithArgs(start, end). + WillReturnRows(sqlmock.NewRows([]string{ + "group_id", "group_name", "requests", "total_tokens", + "cost", "actual_cost", "account_cost", + }). + AddRow(int64(1), "azure-cc", int64(100), int64(5000), 10.0, 8.5, 7.2). + AddRow(int64(2), "max", int64(50), int64(2000), 5.0, 4.0, 3.5)) + + results, err := repo.GetGroupStatsWithFilters(context.Background(), start, end, 0, 0, 0, 0, nil, nil, nil) + require.NoError(t, err) + require.Len(t, results, 2) + require.Equal(t, int64(1), results[0].GroupID) + require.Equal(t, "azure-cc", results[0].GroupName) + require.Equal(t, 10.0, results[0].Cost) + require.Equal(t, 8.5, results[0].ActualCost) + require.Equal(t, 7.2, results[0].AccountCost) + require.Equal(t, int64(2), results[1].GroupID) + require.Equal(t, 3.5, results[1].AccountCost) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUsageLogRepositoryGetStatsWithFiltersAlwaysReturnsAccountCost(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + + // No AccountID filter set - TotalAccountCost should still be returned + filters := usagestats.UsageLogFilters{} + + mock.ExpectQuery("FROM usage_logs"). + WillReturnRows(sqlmock.NewRows([]string{ + "total_requests", "total_input_tokens", "total_output_tokens", + "total_cache_tokens", "total_cost", "total_actual_cost", + "total_account_cost", "avg_duration_ms", + }).AddRow(int64(50), int64(1000), int64(2000), int64(100), 15.0, 12.5, 11.0, 100.0)) + mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\)"). + WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\)"). + WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + mock.ExpectQuery("SELECT CONCAT\\("). + WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + + stats, err := repo.GetStatsWithFilters(context.Background(), filters) + require.NoError(t, err) + require.NotNil(t, stats.TotalAccountCost, "TotalAccountCost must always be returned, even without AccountID filter") + require.Equal(t, 11.0, *stats.TotalAccountCost) require.NoError(t, mock.ExpectationsWereMet()) }