1. 清理测试文件中未使用的导入与冗余代码 2. 修复审计日志与批量操作的空值约束,统一填充"global"作为默认渠道 3. 调整批量消息撤回的响应语义,对齐其他端点的部分成功契约 4. 修复访问规则批量克隆的唯一约束问题,新增后缀自动处理逻辑 5. 替换anyio为asyncio并行调用,修正时间UTC导入路径 6. 优化前端外部系统概览页的刷新状态提示与缓存逻辑 7. 修复测试用例中的断言与请求方式问题,适配httpx删除请求特性 8. 重构后端路由的依赖注入,移除冗余的数据库会话依赖 9. 调整测试用例的权限校验逻辑,修正强制登出的权限判断 10. 修复语义分块测试的numpy依赖问题,清理冗余导入
236 lines
9.5 KiB
Python
236 lines
9.5 KiB
Python
"""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()}
|