feat: 统计数据优化 - 支持细粒度时间范围和多维度分析

- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计
- 实现 UTC datetime 范围查询策略,边界数据实时聚合
- 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类
- 新增前端页面:成本分析、性能分析、用户统计
- 新增 TimeRangePicker 组件和统计可视化组件
- 优化 Dashboard 和 Usage 页面支持时间范围筛选

Close #135
This commit is contained in:
fawney19
2026-02-04 02:04:54 +08:00
parent 6f363a7703
commit f3e2f84b38
59 changed files with 6392 additions and 500 deletions

View File

@@ -0,0 +1,27 @@
import pytest
from src.core.enums import ErrorCategory
from src.services.usage.error_classifier import classify_error
@pytest.mark.parametrize(
"status_code,error_message,status,expected",
[
(429, None, None, ErrorCategory.RATE_LIMIT),
(401, None, None, ErrorCategory.AUTH),
(404, None, None, ErrorCategory.NOT_FOUND),
(None, "maximum context length exceeded", None, ErrorCategory.CONTEXT_LENGTH),
(None, "content_filter triggered", None, ErrorCategory.CONTENT_FILTER),
(None, "rate limit reached", None, ErrorCategory.RATE_LIMIT),
(None, "request timeout", None, ErrorCategory.TIMEOUT),
(None, "connection reset by peer", None, ErrorCategory.NETWORK),
(500, None, None, ErrorCategory.SERVER_ERROR),
(400, None, None, ErrorCategory.INVALID_REQUEST),
(200, None, "cancelled", ErrorCategory.CANCELLED),
(None, None, None, ErrorCategory.UNKNOWN),
],
)
def test_classify_error(
status_code: int | None, error_message: str | None, status: str | None, expected: ErrorCategory
) -> None:
assert classify_error(status_code, error_message, status) == expected

View File

@@ -0,0 +1,274 @@
from datetime import date, datetime, timedelta, timezone
import pytest
from src.services.system.time_range import TimeRangeParams, split_time_range_for_hourly
class TestTimeRangeParams:
# ==================== UTC 转换测试 ====================
def test_to_utc_datetime_range_with_offset(self) -> None:
"""测试带时区偏移的 UTC 转换"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 1),
tz_offset_minutes=480, # UTC+8
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 1, 31, 16, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 1, 16, 0, tzinfo=timezone.utc)
def test_to_utc_datetime_range_negative_offset(self) -> None:
"""测试负时区偏移(如 UTC-5"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 1),
tz_offset_minutes=-300, # UTC-5
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 2, 1, 5, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 2, 5, 0, tzinfo=timezone.utc)
def test_to_utc_datetime_range_utc(self) -> None:
"""测试 UTC 时区(无偏移)"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=0,
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 4, 0, 0, tzinfo=timezone.utc) # end_date + 1 day
# ==================== 预设解析测试 ====================
def test_preset_today(self) -> None:
"""测试 today 预设"""
params = TimeRangeParams(preset="today", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date == params.end_date
def test_preset_yesterday(self) -> None:
"""测试 yesterday 预设"""
params = TimeRangeParams(preset="yesterday", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date == params.end_date
today_params = TimeRangeParams(preset="today", tz_offset_minutes=0)
assert today_params.start_date is not None
assert params.start_date == today_params.start_date - timedelta(days=1)
def test_preset_last7days(self) -> None:
"""测试 last7days 预设(包含今天共 7 天)"""
params = TimeRangeParams(preset="last7days", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
days_diff = (params.end_date - params.start_date).days
assert days_diff == 6 # start 到 end 共 7 天
def test_preset_last30days(self) -> None:
"""测试 last30days 预设"""
params = TimeRangeParams(preset="last30days", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
days_diff = (params.end_date - params.start_date).days
assert days_diff == 29 # start 到 end 共 30 天
def test_preset_this_month(self) -> None:
"""测试 this_month 预设"""
params = TimeRangeParams(preset="this_month", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date.day == 1 # 月初
assert params.start_date.month == params.end_date.month
def test_preset_last_month(self) -> None:
"""测试 last_month 预设"""
params = TimeRangeParams(preset="last_month", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date.day == 1 # 上月初
# end_date 应该是上月最后一天
next_day = params.end_date + timedelta(days=1)
assert next_day.day == 1 # 下一天是本月初
# ==================== 跨年/闰年测试 ====================
def test_cross_year_range(self) -> None:
"""测试跨年日期范围"""
params = TimeRangeParams(
start_date=date(2025, 12, 28),
end_date=date(2026, 1, 3),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc.year == 2025
assert end_utc.year == 2026
days = (params.end_date - params.start_date).days + 1
assert days == 7
def test_leap_year_february(self) -> None:
"""测试闰年 2 月2024 是闰年)"""
params = TimeRangeParams(
start_date=date(2024, 2, 28),
end_date=date(2024, 3, 1),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
start_utc, end_utc = params.to_utc_datetime_range()
days = (params.end_date - params.start_date).days + 1
assert days == 3 # 28, 29, 1
def test_non_leap_year_february(self) -> None:
"""测试非闰年 2 月"""
params = TimeRangeParams(
start_date=date(2025, 2, 28),
end_date=date(2025, 3, 1),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
days = (params.end_date - params.start_date).days + 1
assert days == 2 # 28, 1
# ==================== 验证错误测试 ====================
def test_validate_for_time_series_limit(self) -> None:
"""测试时间序列超过 90 天限制"""
params = TimeRangeParams(
start_date=date(2026, 1, 1),
end_date=date(2026, 4, 5), # 95 days inclusive
tz_offset_minutes=0,
)
with pytest.raises(ValueError):
params.validate_for_time_series()
def test_validate_start_after_end(self) -> None:
"""测试 start_date > end_date 应报错"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2026, 2, 10),
end_date=date(2026, 2, 1),
tz_offset_minutes=0,
)
def test_validate_max_days_exceeded(self) -> None:
"""测试超过 365 天限制"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2025, 1, 1),
end_date=date(2026, 2, 1), # > 365 days
tz_offset_minutes=0,
)
def test_validate_hour_granularity_multi_day(self) -> None:
"""测试小时粒度不支持多天"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 2),
granularity="hour",
tz_offset_minutes=0,
)
def test_validate_missing_dates_and_preset(self) -> None:
"""测试缺少日期和预设应报错"""
with pytest.raises(ValueError):
TimeRangeParams(tz_offset_minutes=0)
# ==================== 完整 UTC 日期分割测试 ====================
def test_get_complete_utc_dates_aligned(self) -> None:
"""测试 UTC 对齐时无头尾边界"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=0, # UTC 对齐
)
assert params.start_date is not None
assert params.end_date is not None
complete_dates, head, tail = params.get_complete_utc_dates()
assert len(complete_dates) == 3
assert head is None
assert tail is None
def test_get_complete_utc_dates_with_offset(self) -> None:
"""测试带偏移时产生头尾边界"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=480, # UTC+8
)
assert params.start_date is not None
assert params.end_date is not None
complete_dates, head, tail = params.get_complete_utc_dates()
# UTC+8 的 2/1 00:00 = UTC 1/31 16:00会产生头边界
assert head is not None or tail is not None or len(complete_dates) < 3
# ==================== 本地日期小时映射测试 ====================
def test_get_local_day_hours(self) -> None:
"""测试本地日期到 UTC 小时映射"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 2),
tz_offset_minutes=480, # UTC+8
)
assert params.start_date is not None
assert params.end_date is not None
local_days = params.get_local_day_hours()
assert len(local_days) == 2
for local_date, start_utc, end_utc in local_days:
assert isinstance(local_date, date)
assert isinstance(start_utc, datetime)
assert isinstance(end_utc, datetime)
# 每天应该正好是 24 小时
assert (end_utc - start_utc).total_seconds() == 24 * 3600
class TestSplitTimeRangeForHourly:
"""测试小时级时间范围分割"""
def test_aligned_hours(self) -> None:
"""测试整点对齐的时间范围"""
start = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 3, 0, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is None
assert tail is None
assert len(complete) == 3
def test_with_head_fragment(self) -> None:
"""测试带头部碎片"""
start = datetime(2026, 2, 1, 0, 30, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 3, 0, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is not None
assert head[0] == start
assert head[1] == datetime(2026, 2, 1, 1, 0, tzinfo=timezone.utc)
assert len(complete) == 2 # 1:00, 2:00
def test_with_tail_fragment(self) -> None:
"""测试带尾部碎片"""
start = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 2, 30, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is None
assert tail is not None
assert tail[0] == datetime(2026, 2, 1, 2, 0, tzinfo=timezone.utc)
assert tail[1] == end
assert len(complete) == 2 # 0:00, 1:00
def test_with_both_fragments(self) -> None:
"""测试同时带头尾碎片"""
start = datetime(2026, 2, 1, 0, 15, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 2, 45, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is not None
assert tail is not None
assert len(complete) == 1 # 只有 1:00-2:00 是完整的