ForcePilot/backend/server/routers/external_systems/metric_router.py
Kris 2f6a4c29bb refactor(channel routers): 统一参数校验与常量定义,新增功能端点
1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用
2. 新增扫码登录向导端点,完善文档说明
3. 优化配对统计接口,移除无效参数
4. 为出站箱接口添加批量上限与202状态码
5. 新增测试用例、访问规则、配额等模块的查询与校验参数
6. 新增适配器健康批量查询、健康检查触发接口
7. 统一告警、审计日志的错误处理方式
8. 新增插件配置账户ID支持,优化批量操作响应
9. 新增环境健康批量查询、Webhook限流与参数校验
10. 完善会话管理、审计日志的参数与文档说明
11. 修复导入模块的校验错误处理逻辑
2026-07-11 21:39:05 +08:00

267 lines
11 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 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()}