ForcePilot/backend/server/routers/external_systems/metric_router.py

267 lines
11 KiB
Python
Raw Normal View History

"""Metric 子域 Router。
外部系统限界上下文的指标查询 API覆盖桶列表 / 聚合 / 时间序列 /
跨系统排名 / 按环境分解 / 最新快照 / Prometheus 导出 / 过期清理
所有端点通过 ``create_use_cases_from_db`` 装配 use_cases
``metric_service`` 端口调用用例
路径顺序约束静态路径aggregate/timeseries/by-system/by-env/latest/prometheus/buckets
必须在根路径之前声明
认证策略
- 查询类端点使用 get_required_user支持 JWT + API Key 双模认证
- 清理端点使用 get_admin_user
- Prometheus 端点复用 get_required_user通过 Authorization: Bearer yxkey_<key> 支持 API Key 抓取
时间范围约束
- aggregate / timeseries / by-system / by-env 均要求 start_time + end_time避免全表扫描
- timeseries 额外限制最大范围hour31 day366 防止结果集膨胀
- by-system / by-env / aggregate 限制最大范围 366
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.external_systems.exceptions import DomainValidationError
from yuxi.external_systems.infrastructure.container import create_use_cases_from_db
from yuxi.external_systems.use_cases.dto.metric import (
DeleteOldBucketsInput,
GetLatestBucketInput,
ListMetricBucketsInput,
MetricAggregateInput,
MetricByEnvInput,
MetricBySystemInput,
MetricTimeseriesInput,
PrometheusExportInput,
)
from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_now_naive
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
metric_router = APIRouter(prefix="/metrics", tags=["external-systems-metric"])
# 最大查询范围约束
_MAX_RANGE_DAYS = 366
_TIMESERIES_MAX_RANGE: dict[str, timedelta] = {
"hour": timedelta(days=31),
"day": timedelta(days=366),
}
def _validate_time_range(start: datetime, end: datetime, max_delta: timedelta) -> None:
"""校验时间范围start 不晚于 end且不超过 max_delta。
使用 DomainValidationError 以遵循 UnifiedError 协议
由全局 ``unified_error_handler`` 统一映射为 HTTP 响应
"""
if start > end:
raise DomainValidationError("start_time 不能晚于 end_time")
if end - start > max_delta:
raise DomainValidationError(f"时间范围不得超过 {max_delta.days}")
# ---------------- Endpoints ----------------
# ---- 静态路径端点(必须在根路径 "" 之前声明) ----
@metric_router.get("/aggregate", response_model=dict)
async def aggregate_metrics(
system_id: int | None = Query(None),
env_key: str | None = Query(None, max_length=32),
adapter_type: str | None = Query(None, max_length=32),
start_time: datetime = Query(..., description="起始时间(必填,避免全表扫描)"),
end_time: datetime = Query(..., description="结束时间(必填,避免全表扫描)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""指标聚合(可按系统/环境/适配器类型过滤)。响应字段对齐 ORM aggregate 返回。"""
_validate_time_range(start_time, end_time, timedelta(days=_MAX_RANGE_DAYS))
use_cases = create_use_cases_from_db(db)
input_dto = MetricAggregateInput(
system_id=system_id,
env_key=env_key,
adapter_type=adapter_type,
start=start_time,
end=end_time,
)
output = await use_cases.metric_service.aggregate(input_dto)
return {"success": True, "data": output.model_dump()}
@metric_router.get("/timeseries", response_model=dict)
async def get_metrics_timeseries(
system_id: int | None = Query(None),
env_key: str | None = Query(None, max_length=32),
adapter_type: str | None = Query(None, max_length=32),
start_time: datetime = Query(..., description="起始时间(必填,避免全表扫描)"),
end_time: datetime = Query(..., description="结束时间(必填,避免全表扫描)"),
interval: Literal["hour", "day"] = Query("hour", description="聚合间隔hour / day"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""时间序列聚合。
最大范围约束hour 间隔 31 day 间隔 366
"""
_validate_time_range(start_time, end_time, _TIMESERIES_MAX_RANGE[interval])
use_cases = create_use_cases_from_db(db)
input_dto = MetricTimeseriesInput(
system_id=system_id,
env_key=env_key,
adapter_type=adapter_type,
start=start_time,
end=end_time,
interval=interval,
)
series = await use_cases.metric_service.timeseries(input_dto)
return {"success": True, "data": {"interval": interval, "series": series}}
@metric_router.get("/by-system", response_model=dict)
async def get_metrics_by_system(
system_id: int | None = Query(None, description="按系统 ID 过滤(可选)"),
env_key: str | None = Query(None, max_length=32),
adapter_type: str | None = Query(None, max_length=32),
start_time: datetime = Query(..., description="起始时间(必填,避免全表扫描)"),
end_time: datetime = Query(..., description="结束时间(必填,避免全表扫描)"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""跨系统聚合排名。按 total_calls 降序,支持 offset 分页。"""
_validate_time_range(start_time, end_time, timedelta(days=_MAX_RANGE_DAYS))
use_cases = create_use_cases_from_db(db)
input_dto = MetricBySystemInput(
system_id=system_id,
env_key=env_key,
adapter_type=adapter_type,
start=start_time,
end=end_time,
limit=limit,
offset=offset,
)
items = await use_cases.metric_service.aggregate_by_system(input_dto)
total = await use_cases.metric_service.count_systems(input_dto)
return {"success": True, "data": {"items": items, "total": total}}
@metric_router.get("/by-env", response_model=dict)
async def get_metrics_by_env(
system_id: int = Query(..., description="系统 ID必填"),
start_time: datetime = Query(..., description="起始时间(必填,避免全表扫描)"),
end_time: datetime = Query(..., description="结束时间(必填,避免全表扫描)"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""单系统按环境分解指标。支持 offset 分页。"""
_validate_time_range(start_time, end_time, timedelta(days=_MAX_RANGE_DAYS))
use_cases = create_use_cases_from_db(db)
input_dto = MetricByEnvInput(
system_id=system_id,
start=start_time,
end=end_time,
limit=limit,
offset=offset,
)
items = await use_cases.metric_service.aggregate_by_env(input_dto)
total = await use_cases.metric_service.count_envs(input_dto)
return {"success": True, "data": {"items": items, "total": total}}
@metric_router.get("/latest", response_model=dict)
async def get_latest_metric_bucket(
system_id: int = Query(..., description="系统 ID必填"),
env_key: str = Query("default", max_length=32, description="环境标识"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""最新桶快照。无数据时 data 为 null。"""
use_cases = create_use_cases_from_db(db)
input_dto = GetLatestBucketInput(system_id=system_id, env_key=env_key)
data = await use_cases.metric_service.get_latest_bucket(input_dto)
return {"success": True, "data": data}
@metric_router.get("/prometheus")
async def export_metrics_prometheus(
start_time: datetime | None = Query(None, description="起始时间(不传时默认最近 1 小时)"),
end_time: datetime | None = Query(None, description="结束时间(不传时默认当前)"),
env_key: str | None = Query(None, max_length=32),
adapter_type: str | None = Query(None, max_length=32),
system_id: int | None = Query(None, description="按系统 ID 过滤(可选)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> Response:
"""Prometheus 格式导出。支持 API Key 认证Authorization: Bearer yxkey_<key>)。
不传时间范围时默认导出最近 1 小时数据支持 adapter_type / system_id 过滤
传入 start_time 时校验 start end 且不超过 366 end_time 未传时以当前时间补全
"""
if start_time is not None:
effective_end = end_time if end_time is not None else utc_now_naive()
_validate_time_range(start_time, effective_end, timedelta(days=_MAX_RANGE_DAYS))
use_cases = create_use_cases_from_db(db)
input_dto = PrometheusExportInput(
start=start_time,
end=end_time,
env_key=env_key,
adapter_type=adapter_type,
system_id=system_id,
)
output = await use_cases.metric_service.export_prometheus(input_dto)
return Response(content=output.content, media_type=output.content_type)
@metric_router.delete("/buckets", response_model=dict)
async def delete_old_metric_buckets(
before: datetime = Query(..., description="清理此时间之前的桶(必填)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""清理过期指标桶软删除。before 必填,避免误删全表。
审计字段 updated_by 记录执行清理的管理员 uid
"""
use_cases = create_use_cases_from_db(db)
input_dto = DeleteOldBucketsInput(before=before, updated_by=current_user.uid)
output = await use_cases.metric_service.delete_old_buckets(input_dto)
return {"success": True, "data": output.model_dump()}
# ---- 根路径端点(最后声明) ----
@metric_router.get("", response_model=dict)
async def list_metric_buckets(
system_id: int | None = Query(None),
env_key: str | None = Query(None, max_length=32),
start_time: datetime | None = Query(None),
end_time: datetime | None = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""桶列表分页查询。返回 Metric dataclass 字段 + avg_latency_ms。"""
use_cases = create_use_cases_from_db(db)
input_dto = ListMetricBucketsInput(
system_id=system_id,
env_key=env_key,
start=start_time,
end=end_time,
limit=limit,
offset=offset,
)
output = await use_cases.metric_service.list_buckets(input_dto)
return {"success": True, "data": output.model_dump()}