ForcePilot/backend/server/routers/external_systems/metric_router.py
Kris f9f08221fc chore: 整理代码风格与优化细节
本次提交包含多类代码优化:
1. 修复多处单行代码换行格式,统一代码排版
2. 为外部系统模块新增快捷菜单配置
3. 优化前端API请求参数命名一致性
4. 补充媒体下载超限错误码与领域异常类
5. 完善仓储层更新逻辑,支持显式清空字段
6. 优化部分测试用例与工具函数代码结构
7. 为微信插件白名单缓存增加过期时间
2026-07-14 15:13:29 +08:00

274 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 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, parse_and_naive, validate_time_range
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_max_range(start: datetime, end: datetime, max_delta: timedelta) -> None:
"""校验时间范围不超过 max_delta时区转换与 start<=end 校验由共享函数完成)。"""
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: str = Query(..., description="起始时间ISO 8601必填避免全表扫描"),
end_time: str = Query(..., description="结束时间ISO 8601必填避免全表扫描"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""指标聚合(可按系统/环境/适配器类型过滤)。响应字段对齐 ORM aggregate 返回。"""
start_dt = parse_and_naive("start_time", start_time)
end_dt = parse_and_naive("end_time", end_time)
validate_time_range(start_dt, end_dt)
_validate_max_range(start_dt, end_dt, timedelta(days=_MAX_RANGE_DAYS))
input_dto = MetricAggregateInput(
system_id=system_id,
env_key=env_key,
adapter_type=adapter_type,
start=start_dt,
end=end_dt,
)
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: str = Query(..., description="起始时间ISO 8601必填避免全表扫描"),
end_time: str = Query(..., description="结束时间ISO 8601必填避免全表扫描"),
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_dt = parse_and_naive("start_time", start_time)
end_dt = parse_and_naive("end_time", end_time)
validate_time_range(start_dt, end_dt)
_validate_max_range(start_dt, end_dt, _TIMESERIES_MAX_RANGE[interval])
input_dto = MetricTimeseriesInput(
system_id=system_id,
env_key=env_key,
adapter_type=adapter_type,
start=start_dt,
end=end_dt,
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: str = Query(..., description="起始时间ISO 8601必填避免全表扫描"),
end_time: str = Query(..., description="结束时间ISO 8601必填避免全表扫描"),
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_dt = parse_and_naive("start_time", start_time)
end_dt = parse_and_naive("end_time", end_time)
validate_time_range(start_dt, end_dt)
_validate_max_range(start_dt, end_dt, timedelta(days=_MAX_RANGE_DAYS))
input_dto = MetricBySystemInput(
system_id=system_id,
env_key=env_key,
adapter_type=adapter_type,
start=start_dt,
end=end_dt,
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: str = Query(..., description="起始时间ISO 8601必填避免全表扫描"),
end_time: str = Query(..., description="结束时间ISO 8601必填避免全表扫描"),
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_dt = parse_and_naive("start_time", start_time)
end_dt = parse_and_naive("end_time", end_time)
validate_time_range(start_dt, end_dt)
_validate_max_range(start_dt, end_dt, timedelta(days=_MAX_RANGE_DAYS))
input_dto = MetricByEnvInput(
system_id=system_id,
start=start_dt,
end=end_dt,
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: str | None = Query(None, description="起始时间ISO 8601不传时默认最近 1 小时)"),
end_time: str | None = Query(None, description="结束时间ISO 8601不传时默认当前"),
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 未传时以当前时间补全)。
"""
start_dt = parse_and_naive("start_time", start_time)
end_dt = parse_and_naive("end_time", end_time)
if start_dt is not None:
effective_end = end_dt if end_dt is not None else utc_now_naive()
validate_time_range(start_dt, effective_end)
_validate_max_range(start_dt, effective_end, timedelta(days=_MAX_RANGE_DAYS))
if end_dt is not None:
end_dt = effective_end
input_dto = PrometheusExportInput(
start=start_dt,
end=end_dt,
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: str = Query(..., description="清理此时间之前的桶ISO 8601必填"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""清理过期指标桶软删除。before 必填,避免误删全表。
审计字段 updated_by 记录执行清理的管理员 uid。
"""
before_dt = parse_and_naive("before", before)
input_dto = DeleteOldBucketsInput(before=before_dt, 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: str | None = Query(None, description="开始时间ISO 8601"),
end_time: str | None = Query(None, description="结束时间ISO 8601"),
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。"""
start_dt = parse_and_naive("start_time", start_time)
end_dt = parse_and_naive("end_time", end_time)
validate_time_range(start_dt, end_dt)
input_dto = ListMetricBucketsInput(
system_id=system_id,
env_key=env_key,
start=start_dt,
end=end_dt,
limit=limit,
offset=offset,
)
output = await use_cases.metric_service.list_buckets(input_dto)
return {"success": True, "data": output.model_dump()}