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

236 lines
9.5 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.

"""NotificationChannel 子域 Router。
外部系统限界上下文的通知渠道管理 API覆盖通知渠道列表 / 详情 / 创建 /
更新 / 删除 / 统计 / 测试。所有端点通过 ``create_use_cases_from_db`` 装配 use_cases
经 ``notification_channel_service`` 端口调用用例。
路径顺序约束:静态路径(``/stats``)必须在 ``/{channel_id}`` 前声明,
避免被路径参数捕获。``POST /{channel_id}/test`` 与 ``POST ""`` 路径模式不重叠,
无需额外排序。
认证策略:查询类端点使用 ``get_required_user``,写操作端点(含测试)使用 ``get_admin_user``。
校验层次:
- **边界校验Pydantic**``channel_type`` 使用 ``ChannelTypeLiteral`` 类型,
Pydantic 在请求反序列化时即拒绝非法类型,并在 OpenAPI schema 中生成枚举。
- **领域校验framework**``validate_channel_config`` 按 ``channel_type``
校验 ``config`` 必填字段与 URL 协议,抛 ``DomainValidationError``(映射 400
创建时直接校验;更新时先读取现有渠道的 ``channel_type`` 再校验 ``config``。
Request Schema 与 Input DTO 不共享类Router 内显式构造 DTO创建 / 更新 / 删除
操作的 ``created_by`` / ``updated_by`` 字段由 ``current_user.uid`` 填充。
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, ConfigDict, Field
from yuxi.external_systems.framework.adapters.notifications import (
ChannelTypeLiteral,
validate_channel_config,
)
from yuxi.external_systems.infrastructure.container import UseCases
from yuxi.external_systems.use_cases.dto.notification_channel import (
CreateNotificationChannelInput,
DeleteChannelInput,
GetChannelInput,
ListNotificationChannelsInput,
NotificationChannelStatsInput,
TestChannelInput,
UpdateChannelInput,
)
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
notification_channel_router = APIRouter(
prefix="/notification-channels",
tags=["external-systems-notification-channel"],
)
# ---------------- Request Schemas ----------------
class CreateNotificationChannelRequest(BaseModel):
"""创建通知渠道请求体。字段对齐 ``CreateNotificationChannelInput``(不含 created_by
字段长度约束对齐 ``ExternalNotificationChannel`` ORM 列定义,在边界层拦截非法输入:
``channel_type`` 对齐 String(64) 且必须是支持的类型枚举,
``name`` 对齐 String(128)``description`` 对齐 Text。
"""
model_config = ConfigDict(frozen=True)
channel_type: ChannelTypeLiteral
name: str = Field(..., min_length=1, max_length=128)
config: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
description: str | None = Field(default=None, max_length=2000)
class UpdateNotificationChannelRequest(BaseModel):
"""更新通知渠道请求体。字段对齐 ``UpdateChannelInput``(不含 id / updated_by
字段长度约束对齐 ``ExternalNotificationChannel`` ORM 列定义,仅透传客户端显式设置的字段。
``channel_type`` 不可更新(渠道类型一经确定不可变更),如需变更应删除重建。
"""
model_config = ConfigDict(frozen=True)
name: str | None = Field(default=None, min_length=1, max_length=128)
config: dict[str, Any] | None = None
enabled: bool | None = None
description: str | None = Field(default=None, max_length=2000)
class TestNotificationChannelRequest(BaseModel):
"""测试通知渠道请求体。``message`` 可选,未传时由 service 使用默认测试消息。"""
model_config = ConfigDict(frozen=True)
message: str | None = Field(default=None, max_length=2000)
# ---------------- Endpoints ----------------
@notification_channel_router.get("", response_model=dict)
async def list_notification_channels(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
channel_type: ChannelTypeLiteral | None = Query(None),
enabled: bool | None = Query(None),
keyword: str | None = Query(None, max_length=128),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页查询通知渠道列表(支持 channel_type / enabled / keyword 过滤)。"""
input_dto = ListNotificationChannelsInput(
limit=limit,
offset=offset,
channel_type=channel_type,
enabled=enabled,
keyword=keyword,
)
output = await use_cases.notification_channel_service.list_channels(input_dto)
return {"success": True, "data": output.model_dump()}
@notification_channel_router.get("/stats", response_model=dict)
async def get_notification_channel_stats(
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询通知渠道统计(按 channel_type 分组 + 按启用状态分组)。"""
input_dto = NotificationChannelStatsInput()
output = await use_cases.notification_channel_service.get_channel_stats(input_dto)
return {"success": True, "data": output.model_dump()}
@notification_channel_router.get("/{channel_id}", response_model=dict)
async def get_notification_channel(
channel_id: int = Path(ge=1),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询通知渠道详情。"""
input_dto = GetChannelInput(id=channel_id)
output = await use_cases.notification_channel_service.get_channel(input_dto)
return {"success": True, "data": output.model_dump()}
@notification_channel_router.post("", response_model=dict)
async def create_notification_channel(
payload: CreateNotificationChannelRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建通知渠道。
``created_by`` 由当前管理员填充。
``channel_type`` 通过 ``ChannelTypeLiteral`` 在 Pydantic 层完成枚举校验,
``config`` 通过 ``validate_channel_config`` 按 ``channel_type`` 校验必填字段与 URL 协议。
"""
validate_channel_config(payload.channel_type, payload.config)
input_dto = CreateNotificationChannelInput(
channel_type=payload.channel_type,
name=payload.name,
config=payload.config,
enabled=payload.enabled,
description=payload.description,
created_by=current_user.uid,
)
output = await use_cases.notification_channel_service.create_channel(input_dto)
return {"success": True, "data": output.model_dump()}
@notification_channel_router.put("/{channel_id}", response_model=dict)
async def update_notification_channel(
payload: UpdateNotificationChannelRequest,
channel_id: int = Path(ge=1),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新通知渠道。
仅透传客户端显式设置的字段。``updated_by`` 由当前管理员填充。
若更新包含 ``config``,先读取现有渠道的 ``channel_type``,再按该类型校验
``config`` 结构(``channel_type`` 不可变更,校验基于已持久化的类型)。
"""
if payload.config is not None:
existing = await use_cases.notification_channel_service.get_channel(
GetChannelInput(id=channel_id),
)
validate_channel_config(existing.channel_type, payload.config)
input_dto = UpdateChannelInput(
id=channel_id,
updated_by=current_user.uid,
**payload.model_dump(exclude_unset=True),
)
output = await use_cases.notification_channel_service.update_channel(input_dto)
return {"success": True, "data": output.model_dump()}
@notification_channel_router.delete("/{channel_id}", response_model=dict)
async def delete_notification_channel(
channel_id: int = Path(ge=1),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除通知渠道(软删除)。
``updated_by`` 由当前管理员填充,写入 ORM 记录确保软删除可追溯操作人。
若渠道被活跃告警引用service 抛 ``ReferencedError``(映射 409
"""
input_dto = DeleteChannelInput(
id=channel_id,
updated_by=current_user.uid,
)
output = await use_cases.notification_channel_service.delete_channel(input_dto)
return {"success": True, "data": output.model_dump()}
@notification_channel_router.post("/{channel_id}/test", response_model=dict)
async def test_notification_channel(
payload: TestNotificationChannelRequest,
channel_id: int = Path(ge=1),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""测试通知渠道连通性。
构造测试 ``NotificationMessage``,通过 ``NotificationDispatcher`` 派发到渠道,
返回派发结果(成功/失败 + 错误信息 + HTTP 状态码 + 耗时)。
不持久化任何状态,仅用于验证渠道配置是否正确。
"""
input_dto = TestChannelInput(
id=channel_id,
message=payload.message,
)
output = await use_cases.notification_channel_service.test_channel(input_dto)
return {"success": True, "data": output.model_dump()}