ForcePilot/backend/server/routers/external_systems/alert_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

345 lines
14 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.

"""Alert 子域 Router。
外部系统限界上下文的告警事件管理 API覆盖告警列表 / 详情 / 触发 /
确认 / 恢复 / 统计聚合 / 按链路查询 / 批量确认 / 批量恢复 / 告警上下文 /
通知状态。所有端点通过 ``create_use_cases_from_db`` 装配 use_cases
经 ``alert_service`` 端口调用用例。
设计要点:
- ``list_alerts`` 支持 keyword / resource_type / resource_id / related_trace_id /
dedup_key / 时间范围 多维过滤,结果携带 ``system_name`` / ``system_slug``LEFT JOIN 填充)。
- ``get_alert_stats`` 支持 system_id / env_key / 时间范围 过滤,聚合按 status / severity /
alert_type 分组与 Top 告警排行。
- ``list_alerts_by_trace`` 路径参数 ``trace_id`` 通过 ``Path(min_length=1, max_length=64)``
约束,避免空串与超长输入。
- ``fire_alert`` 显式字段映射构造 ``FireAlertInput````triggered_by`` 由当前管理员 uid 填充;
若 ``system_id`` 非空但系统不存在Service 层抛 404同一 ``dedup_key`` 在 firing 状态下去重更新。
- ``acknowledge_alert`` / ``resolve_alert`` 在告警不存在时返回 404状态冲突时返回 409
(由 Service 层区分并通过 unified_error_handler 自动映射)。
- Request Schema 与 Input DTO 不共享类Router 内显式构造 DTO确保 API 边界与用例边界解耦。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from fastapi import APIRouter, Body, Depends, Path, Query
from pydantic import BaseModel, ConfigDict, Field
from yuxi.external_systems.exceptions import DomainValidationError
from yuxi.external_systems.infrastructure.container import UseCases
from yuxi.external_systems.use_cases.dto.alert import (
AcknowledgeAlertInput,
AlertStatsInput,
BatchAcknowledgeAlertsInput,
BatchResolveAlertsInput,
FireAlertInput,
GetAlertContextInput,
GetAlertInput,
GetAlertNotificationsInput,
ListAlertsByTraceInput,
ListAlertsInput,
ResolveAlertInput,
)
from yuxi.storage.postgres.models_business import User
from server.routers.external_systems import get_use_cases
from server.utils.auth_middleware import get_admin_user, get_required_user
alert_router = APIRouter(prefix="/alerts", tags=["external-systems-alert"])
# ---------------- Request Schemas ----------------
class FireAlertRequest(BaseModel):
"""触发告警请求体。字段对齐 ``FireAlertInput``。"""
model_config = ConfigDict(frozen=True)
system_id: int | None = Field(default=None, ge=1)
alert_type: str = Field(..., min_length=1, max_length=64)
title: str = Field(..., min_length=1, max_length=256)
triggered_at: datetime
env_key: str | None = Field(default=None, max_length=32)
severity: Literal["info", "warning", "critical", "fatal"] = "warning"
description: str | None = None
detail: dict[str, Any] = Field(default_factory=dict)
resource_type: str | None = Field(default=None, max_length=32)
resource_id: str | None = Field(default=None, max_length=128)
related_execution_id: str | None = Field(default=None, max_length=64)
related_trace_id: str | None = Field(default=None, max_length=64)
metric_snapshot: dict[str, Any] | None = None
dedup_key: str | None = Field(default=None, max_length=256)
class AcknowledgeAlertRequest(BaseModel):
"""确认告警请求体。``acknowledged_by`` 由当前管理员填充,仅暴露 ``note``。"""
model_config = ConfigDict(frozen=True)
note: str | None = None
class ResolveAlertRequest(BaseModel):
"""恢复告警请求体。字段对齐 ``ResolveAlertInput``(不含 id / user"""
model_config = ConfigDict(frozen=True)
resolution_note: str | None = None
class BatchAcknowledgeAlertsRequest(BaseModel):
"""批量确认告警请求体。字段对齐 ``BatchAcknowledgeAlertsInput``(不含 acknowledged_by"""
model_config = ConfigDict(frozen=True)
alert_ids: list[int] = Field(..., min_length=1, max_length=100)
note: str | None = None
class BatchResolveAlertsRequest(BaseModel):
"""批量恢复告警请求体。字段对齐 ``BatchResolveAlertsInput``(不含 user"""
model_config = ConfigDict(frozen=True)
alert_ids: list[int] = Field(..., min_length=1, max_length=100)
resolution_note: str | None = None
# ---------------- Endpoints ----------------
@alert_router.get("", response_model=dict)
async def list_alerts(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
system_id: int | None = Query(None, ge=1),
env_key: str | None = Query(None, max_length=32),
alert_type: str | None = Query(None, max_length=64),
severity: Literal["info", "warning", "critical", "fatal"] | None = Query(
None, description="严重度info/warning/critical/fatal"
),
status: Literal["firing", "acknowledged", "resolved", "suppressed"] | None = Query(
None, description="告警状态firing/acknowledged/resolved/suppressed"
),
start_time: datetime | None = Query(None, description="ISO8601 开始时间"),
end_time: datetime | None = Query(None, description="ISO8601 结束时间"),
keyword: str | None = Query(
None, max_length=128, description="关键词搜索title/description/alert_type/system name/slug"
),
# 新增查询参数
resource_type: str | None = Query(None, max_length=32),
resource_id: str | None = Query(None, max_length=128),
related_trace_id: str | None = Query(None, max_length=64),
dedup_key: str | None = Query(None, max_length=256),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出告警事件(支持 keyword / resource_type / resource_id / related_trace_id / dedup_key 过滤)。
时间范围校验:若 ``start_time`` 晚于 ``end_time``,抛 ``DomainValidationError``
(由全局 ``unified_error_handler`` 统一映射为 400 + 项目统一错误格式)。
列表结果包含 ``system_name`` / ``system_slug``(由 LEFT JOIN 填充)。
"""
if start_time is not None and end_time is not None and start_time > end_time:
raise DomainValidationError("start_time 不能晚于 end_time")
input_dto = ListAlertsInput(
limit=limit,
offset=offset,
system_id=system_id,
env_key=env_key,
alert_type=alert_type,
severity=severity,
status=status,
start_at=start_time,
end_at=end_time,
keyword=keyword,
resource_type=resource_type,
resource_id=resource_id,
related_trace_id=related_trace_id,
dedup_key=dedup_key,
)
output = await use_cases.alert_service.list_alerts(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.get("/stats", response_model=dict)
async def get_alert_stats(
system_id: int | None = Query(None, ge=1),
env_key: str | None = Query(None, max_length=32),
start_time: datetime | None = Query(None, description="ISO8601 开始时间"),
end_time: datetime | None = Query(None, description="ISO8601 结束时间"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询告警统计聚合(支持 system_id / env_key / 时间范围过滤)。
时间范围基于 ``triggered_at`` 字段,若 ``start_time`` 晚于 ``end_time`` 抛
``DomainValidationError``(由全局 ``unified_error_handler`` 统一映射为 400
"""
if start_time is not None and end_time is not None and start_time > end_time:
raise DomainValidationError("start_time 不能晚于 end_time")
input_dto = AlertStatsInput(
system_id=system_id,
env_key=env_key,
start_at=start_time,
end_at=end_time,
)
output = await use_cases.alert_service.get_alert_stats(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.get("/by-trace/{trace_id}", response_model=dict)
async def list_alerts_by_trace(
trace_id: str = Path(..., min_length=1, max_length=64, description="链路 ID"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""按链路 ID 查询告警(通过 related_trace_id 关联,支持分页)。"""
input_dto = ListAlertsByTraceInput(
trace_id=trace_id,
limit=limit,
offset=offset,
)
output = await use_cases.alert_service.list_alerts_by_trace(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.post("/batch-acknowledge", response_model=dict)
async def batch_acknowledge_alerts(
payload: BatchAcknowledgeAlertsRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量确认告警。``acknowledged_by`` 由当前管理员填充。"""
input_dto = BatchAcknowledgeAlertsInput(
alert_ids=payload.alert_ids,
note=payload.note,
acknowledged_by=current_user.uid,
)
output = await use_cases.alert_service.batch_acknowledge_alerts(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.post("/batch-resolve", response_model=dict)
async def batch_resolve_alerts(
payload: BatchResolveAlertsRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量恢复告警。``user`` 由当前管理员填充,确保审计可追溯。"""
input_dto = BatchResolveAlertsInput(
alert_ids=payload.alert_ids,
user=current_user.uid,
resolution_note=payload.resolution_note,
)
output = await use_cases.alert_service.batch_resolve_alerts(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.get("/{alert_id}", response_model=dict)
async def get_alert(
alert_id: int = Path(ge=1),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取告警详情。"""
input_dto = GetAlertInput(id=alert_id)
output = await use_cases.alert_service.get_alert(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.post("", response_model=dict)
async def fire_alert(
payload: FireAlertRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""触发告警事件(含去重与自动通知派发)。
``triggered_by`` 由当前管理员 uid 填充,确保审计可追溯。
若 ``system_id`` 非空但对应系统不存在Service 层抛 404。
同一 ``dedup_key`` 在 ``firing`` 状态下重复触发时更新已有记录。
"""
input_dto = FireAlertInput(
system_id=payload.system_id,
alert_type=payload.alert_type,
title=payload.title,
triggered_at=payload.triggered_at,
env_key=payload.env_key,
severity=payload.severity,
description=payload.description,
detail=payload.detail,
resource_type=payload.resource_type,
resource_id=payload.resource_id,
related_execution_id=payload.related_execution_id,
related_trace_id=payload.related_trace_id,
metric_snapshot=payload.metric_snapshot,
dedup_key=payload.dedup_key,
triggered_by=current_user.uid,
)
output = await use_cases.alert_service.fire_alert(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.post("/{alert_id}/acknowledge", response_model=dict)
async def acknowledge_alert(
alert_id: int = Path(ge=1),
payload: AcknowledgeAlertRequest = Body(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""确认告警。``acknowledged_by`` 由当前管理员 uid 填充,``note`` 可选。"""
input_dto = AcknowledgeAlertInput(
id=alert_id,
acknowledged_by=current_user.uid,
note=payload.note,
)
output = await use_cases.alert_service.acknowledge_alert(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.post("/{alert_id}/resolve", response_model=dict)
async def resolve_alert(
alert_id: int = Path(ge=1),
payload: ResolveAlertRequest = Body(...),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""恢复告警。``user`` 由当前管理员 uid 填充,确保审计可追溯。"""
input_dto = ResolveAlertInput(
id=alert_id,
user=current_user.uid,
resolution_note=payload.resolution_note,
)
output = await use_cases.alert_service.resolve_alert(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.get("/{alert_id}/context", response_model=dict)
async def get_alert_context(
alert_id: int = Path(ge=1),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询告警上下文(含执行记录 + 指标快照)。"""
input_dto = GetAlertContextInput(alert_id=alert_id)
output = await use_cases.alert_service.get_alert_context(input_dto)
return {"success": True, "data": output.model_dump()}
@alert_router.get("/{alert_id}/notifications", response_model=dict)
async def get_alert_notifications(
alert_id: int = Path(ge=1),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询告警通知状态。"""
input_dto = GetAlertNotificationsInput(alert_id=alert_id)
output = await use_cases.alert_service.get_alert_notifications(input_dto)
return {"success": True, "data": output.model_dump()}