ForcePilot/backend/server/routers/external_systems/metric_router.py
Kris 002e6a356b chore: 批量整理代码变更,修复多类细节问题
1.  清理测试文件中未使用的导入与冗余代码
2.  修复审计日志与批量操作的空值约束,统一填充"global"作为默认渠道
3.  调整批量消息撤回的响应语义,对齐其他端点的部分成功契约
4.  修复访问规则批量克隆的唯一约束问题,新增后缀自动处理逻辑
5.  替换anyio为asyncio并行调用,修正时间UTC导入路径
6.  优化前端外部系统概览页的刷新状态提示与缓存逻辑
7.  修复测试用例中的断言与请求方式问题,适配httpx删除请求特性
8.  重构后端路由的依赖注入,移除冗余的数据库会话依赖
9.  调整测试用例的权限校验逻辑,修正强制登出的权限判断
10. 修复语义分块测试的numpy依赖问题,清理冗余导入
2026-07-13 20:48:29 +08:00

284 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 额外限制最大范围hour→31 天day→366 天),防止结果集膨胀
- by-system / by-env / aggregate 限制最大范围 366 天
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Response
from yuxi.external_systems.exceptions import DomainValidationError
from yuxi.external_systems.infrastructure.container import UseCases
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.routers.external_systems import get_use_cases
from server.utils.auth_middleware import get_admin_user, 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
) -> tuple[datetime, datetime]:
"""校验时间范围start 不晚于 end且不超过 max_delta。
同时将 timezone-aware datetime 归一化为 naive UTC以适配 DB 列
``TIMESTAMP WITHOUT TIME ZONE``,避免 offset-naive/aware 相减报错。
返回归一化后的 ``(start, end)``。
使用 DomainValidationError 以遵循 UnifiedError 协议,
由全局 ``unified_error_handler`` 统一映射为 HTTP 响应。
"""
if start.tzinfo is not None:
start = start.astimezone(UTC).replace(tzinfo=None)
if end.tzinfo is not None:
end = end.astimezone(UTC).replace(tzinfo=None)
if start > end:
raise DomainValidationError("start_time 不能晚于 end_time")
if end - start > max_delta:
raise DomainValidationError(f"时间范围不得超过 {max_delta.days}")
return start, end
# ---------------- 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="结束时间(必填,避免全表扫描)"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""指标聚合(可按系统/环境/适配器类型过滤)。响应字段对齐 ORM aggregate 返回。"""
start_time, end_time = _validate_time_range(
start_time, end_time, timedelta(days=_MAX_RANGE_DAYS)
)
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"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""时间序列聚合。
最大范围约束hour 间隔 ≤ 31 天day 间隔 ≤ 366 天。
"""
start_time, end_time = _validate_time_range(
start_time, end_time, _TIMESERIES_MAX_RANGE[interval]
)
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),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""跨系统聚合排名。按 total_calls 降序,支持 offset 分页。"""
start_time, end_time = _validate_time_range(
start_time, end_time, timedelta(days=_MAX_RANGE_DAYS)
)
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),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""单系统按环境分解指标。支持 offset 分页。"""
start_time, end_time = _validate_time_range(
start_time, end_time, timedelta(days=_MAX_RANGE_DAYS)
)
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="环境标识"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""最新桶快照。无数据时 data 为 null。"""
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 过滤(可选)"),
use_cases: UseCases = Depends(get_use_cases),
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()
start_time, effective_end = _validate_time_range(
start_time, effective_end, timedelta(days=_MAX_RANGE_DAYS)
)
if end_time is not None:
end_time = effective_end
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="清理此时间之前的桶(必填)"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""清理过期指标桶软删除。before 必填,避免误删全表。
审计字段 updated_by 记录执行清理的管理员 uid。
"""
if before.tzinfo is not None:
before = before.astimezone(UTC).replace(tzinfo=None)
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),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""桶列表分页查询。返回 Metric dataclass 字段 + avg_latency_ms。"""
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()}