"""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 sqlalchemy.ext.asyncio import AsyncSession from yuxi.external_systems.framework.adapters.notifications import ( ChannelTypeLiteral, validate_channel_config, ) from yuxi.external_systems.infrastructure.container import create_use_cases_from_db 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.utils.auth_middleware import get_admin_user, get_db, 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """分页查询通知渠道列表(支持 channel_type / enabled / keyword 过滤)。""" use_cases = create_use_cases_from_db(db) 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( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """查询通知渠道统计(按 channel_type 分组 + 按启用状态分组)。""" use_cases = create_use_cases_from_db(db) 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """查询通知渠道详情。""" use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), 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) use_cases = create_use_cases_from_db(db) 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """更新通知渠道。 仅透传客户端显式设置的字段。``updated_by`` 由当前管理员填充。 若更新包含 ``config``,先读取现有渠道的 ``channel_type``,再按该类型校验 ``config`` 结构(``channel_type`` 不可变更,校验基于已持久化的类型)。 """ use_cases = create_use_cases_from_db(db) 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """删除通知渠道(软删除)。 ``updated_by`` 由当前管理员填充,写入 ORM 记录确保软删除可追溯操作人。 若渠道被活跃告警引用,service 抛 ``ReferencedError``(映射 409)。 """ use_cases = create_use_cases_from_db(db) 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """测试通知渠道连通性。 构造测试 ``NotificationMessage``,通过 ``NotificationDispatcher`` 派发到渠道, 返回派发结果(成功/失败 + 错误信息 + HTTP 状态码 + 耗时)。 不持久化任何状态,仅用于验证渠道配置是否正确。 """ use_cases = create_use_cases_from_db(db) 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()}