本次提交包含多类优化:
1. 移除多个路由文件中多余的空导入行,统一代码格式
2. 重构Query参数定义,将长参数拆分为多行提升可读性
3. 新增多个业务端点:
- 渠道能力画像矩阵查询CAP-03
- 配对审批计数接口用于待办角标
- 批量查询对端目录资料接口
- 向导扫码登录相关端点
- 会话实时事件SSE推送端点
- 工作台待办统计接口
4. 完善异常处理逻辑,补充OperationTimeoutError导入并优化NotImplementedError的细节返回
5. 调整路由导入顺序,修复动态路由路径冲突隐患
6. 更新文档注释与接口清单,修正部分接口描述细节
479 lines
21 KiB
Python
479 lines
21 KiB
Python
"""channels 模块 Router 共享层。
|
||
|
||
为 channels 资源域的各子 Router(session / channel / account 等)提供共享
|
||
基础设施:DI 容器访问、UseCase 装配、操作人构建、控制面结果序列化与失败
|
||
转译。子 Router 通过 ``Depends(get_channel_use_cases)`` 注入已装配的
|
||
``ChannelUseCases``,通过 ``build_operator`` 构造审计用操作人,通过
|
||
``raiseOnControlFailure`` 将 ``ControlResult`` 中的失败统一转译为契约层异常,
|
||
由全局异常处理器映射为 HTTP 响应。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Any, Literal
|
||
|
||
from fastapi import APIRouter, Depends, Request
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from yuxi.channels.contract.dtos.common import Operator, OperatorRole
|
||
from yuxi.channels.contract.dtos.control import ControlResult
|
||
from yuxi.channels.contract.errors import (
|
||
AuthError,
|
||
BotLoopBudgetExceededError,
|
||
CapabilityNotProvenError,
|
||
ChannelDegradedError,
|
||
ConfigNotHotReloadableError,
|
||
ConfigRollbackError,
|
||
ConflictError,
|
||
ContentViolationError,
|
||
DependencyError,
|
||
DmDeniedError,
|
||
InternalError,
|
||
LifecycleHookError,
|
||
NotFoundError,
|
||
NotImplementedError,
|
||
OperationTimeoutError,
|
||
PairingExpiredError,
|
||
PermissionDeniedError,
|
||
PipelineConfigError,
|
||
PluginAlreadyRegisteredError,
|
||
PluginFailedError,
|
||
PluginNotFoundError,
|
||
RateLimitError,
|
||
RuleViolationError,
|
||
SchemaInitializationError,
|
||
ValidationError,
|
||
)
|
||
from yuxi.channels.infrastructure import DependencyInjectionContainer
|
||
from yuxi.channels.infrastructure.channel_use_cases import (
|
||
ChannelUseCases,
|
||
create_channel_use_cases,
|
||
)
|
||
from yuxi.storage.postgres.models_business import User
|
||
from yuxi.utils.crypto import SENSITIVE_HTTP_HEADERS
|
||
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime
|
||
from yuxi.utils.trace_context import get_trace_id
|
||
|
||
from server.utils.auth_middleware import get_db
|
||
|
||
# 时间粒度查询参数类型(analytics / dashboard / pairing / content_review / outbox 共享)
|
||
Granularity = Literal["hour", "day", "week"]
|
||
|
||
|
||
async def get_channel_di_container(request: Request) -> DependencyInjectionContainer:
|
||
"""从 app.state 获取 channels 模块 DI 容器。
|
||
|
||
channels 模块在应用 lifespan 中完成 DI 容器装配并挂载至
|
||
``app.state.channel_di_container``。本依赖在请求进入时取出容器,未初始化
|
||
时抛 ``InternalError``(HTTP 500),交由全局异常处理器映射,避免后续
|
||
装配出现 NoneType 错误。
|
||
|
||
Args:
|
||
request: 当前请求对象,用于访问 ``app.state``。
|
||
|
||
Returns:
|
||
已装配的 ``DependencyInjectionContainer``。
|
||
|
||
Raises:
|
||
InternalError: 容器未初始化时抛出,表示应用 lifespan 装配异常。
|
||
"""
|
||
container = getattr(request.app.state, "channel_di_container", None)
|
||
if container is None:
|
||
raise InternalError(message="channels module not initialized")
|
||
return container
|
||
|
||
|
||
async def get_channel_use_cases(
|
||
db: AsyncSession = Depends(get_db),
|
||
di_container: DependencyInjectionContainer = Depends(get_channel_di_container),
|
||
) -> ChannelUseCases:
|
||
"""装配 channels 控制面 UseCase 集合。
|
||
|
||
将请求级 DB 会话与 DI 容器传入 ``create_channel_use_cases``,构造本次
|
||
请求可用的 ``ChannelUseCases``。UseCase 内部端口方法在控制面管道事务中
|
||
执行。
|
||
|
||
Args:
|
||
db: 请求级异步 DB 会话。
|
||
di_container: channels 模块 DI 容器。
|
||
|
||
Returns:
|
||
已装配的 ``ChannelUseCases``,含 ``session_management`` 等端口字段。
|
||
"""
|
||
return create_channel_use_cases(db, di_container)
|
||
|
||
|
||
def build_operator(user: User, request: Request) -> Operator:
|
||
"""从已认证用户与请求构造审计用操作人。
|
||
|
||
将 ``User.role`` 映射为契约层 ``OperatorRole``,未命中 admin/superadmin
|
||
时降级为 ``REQUIRED_USER``(实际由 ``get_admin_user`` 守门,此处仅做映射)。
|
||
同时提取来源 IP 与 trace_id 用于审计与链路追踪。
|
||
|
||
trace_id 优先取 ``X-Request-Id`` 头(与 ``TraceIdMiddleware`` 一致),
|
||
缺失时从 ContextVar 兜底(已由 middleware 在请求入口生成),保证
|
||
``Operator.request_id`` 与 HTTP 响应头 ``X-Request-Id`` 及全局异常处理器
|
||
的 ``trace_id`` 三者一致,避免日志关联断裂。
|
||
|
||
Args:
|
||
user: 已通过 ``get_admin_user`` 校验的当前用户。
|
||
request: 当前请求对象,用于读取 client 与请求头。
|
||
|
||
Returns:
|
||
不可变 ``Operator`` 值对象。
|
||
"""
|
||
role_map = {"superadmin": OperatorRole.SUPERADMIN_USER, "admin": OperatorRole.ADMIN_USER}
|
||
role = role_map.get(user.role, OperatorRole.REQUIRED_USER)
|
||
ip = request.client.host if request.client else None
|
||
request_id = request.headers.get("X-Request-Id") or get_trace_id()
|
||
return Operator(user_id=user.uid, role=role, ip=ip, request_id=request_id)
|
||
|
||
|
||
def serialize_control_data(obj: Any) -> Any:
|
||
"""递归序列化控制面返回数据为 JSON 兼容结构。
|
||
|
||
控制面 ``ControlResult.data`` 可能携带 frozen dataclass、Enum、datetime
|
||
等非 JSON 原生类型,本函数递归转换为 dict / list / 标量:
|
||
|
||
- ``None`` 原样返回。
|
||
- ``dict`` 递归处理每个 value。
|
||
- ``list`` / ``tuple`` 递归处理每个元素并统一为 list。
|
||
- ``Enum`` 取 ``value``。
|
||
- ``datetime`` 取 ``isoformat()``。
|
||
- dataclass 实例按字段展开为 dict 并递归处理。
|
||
- 其它类型原样返回。
|
||
|
||
Args:
|
||
obj: 待序列化的任意对象。
|
||
|
||
Returns:
|
||
JSON 兼容的序列化结果。
|
||
"""
|
||
if obj is None:
|
||
return None
|
||
if isinstance(obj, dict):
|
||
return {k: serialize_control_data(v) for k, v in obj.items()}
|
||
if isinstance(obj, (list, tuple)):
|
||
return [serialize_control_data(item) for item in obj]
|
||
if isinstance(obj, Enum):
|
||
return obj.value
|
||
if isinstance(obj, datetime):
|
||
return obj.isoformat()
|
||
if dataclasses.is_dataclass(obj):
|
||
return {f.name: serialize_control_data(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
|
||
return obj
|
||
|
||
|
||
def dataclass_to_dict(obj: Any) -> Any:
|
||
"""将 dataclass 实例序列化为 JSON 兼容结构。
|
||
|
||
委托 ``serialize_control_data`` 完成实际递归序列化,提供语义化的入口
|
||
便于调用方表达"仅接受 dataclass 输入"的意图。返回类型标注为 ``Any``
|
||
以与 ``serialize_control_data`` 对齐:输入为 dataclass 时返回 dict,
|
||
输入为 dict / list 时返回对应容器,标量原样返回。
|
||
|
||
Args:
|
||
obj: dataclass 实例(也兼容 dict / list / 标量)。
|
||
|
||
Returns:
|
||
序列化后的 JSON 兼容结构。
|
||
"""
|
||
return serialize_control_data(obj)
|
||
|
||
|
||
def raiseOnControlFailure(result: ControlResult) -> None:
|
||
"""将控制面 ``ControlResult`` 的失败状态转译为契约层异常。
|
||
|
||
控制面端口方法返回 ``ControlResult`` 而非直接抛异常,Router 在拿到结果后
|
||
调用本函数:``status`` 为 ``"success"`` 或 ``"partial"`` 时直接返回
|
||
(``partial`` 表示批量操作部分成功,由 router 在响应 envelope 中通过
|
||
``result.status`` 反映部分成功语义);否则取首个 ``FailureDetail``
|
||
按 ``error_code`` 反向重构对应的契约层异常,交由全局异常处理器映射为 HTTP
|
||
响应。``trace_id`` 不显式传递,由全局异常处理器统一填充。
|
||
|
||
错误码映射表:
|
||
|
||
+------------------------------+-------------------------------------+
|
||
| error_code | 异常 |
|
||
+------------------------------+-------------------------------------+
|
||
| VALIDATION_ERROR | ValidationError |
|
||
| CONFIG_VALIDATION | ValidationError |
|
||
| AUTH_ERROR | AuthError |
|
||
| NOT_FOUND | NotFoundError |
|
||
| PLUGIN_NOT_FOUND | PluginNotFoundError |
|
||
| PERMISSION_DENIED | PermissionDeniedError |
|
||
| DM_DENIED | DmDeniedError |
|
||
| CONFLICT | ConflictError |
|
||
| CONFIG_VERSION_CONFLICT | ConflictError |
|
||
| CONFIG_RESTART_REQUIRED | ConflictError |
|
||
| PLUGIN_ALREADY_REGISTERED | PluginAlreadyRegisteredError |
|
||
| RULE_VIOLATION | RuleViolationError |
|
||
| CAPABILITY_NOT_PROVEN | CapabilityNotProvenError |
|
||
| CONFIG_NOT_HOT_RELOADABLE | ConfigNotHotReloadableError |
|
||
| CONFIG_ROLLBACK_FAILED | ConfigRollbackError |
|
||
| SCHEMA_INIT_FAILED | SchemaInitializationError |
|
||
| CHANNEL_DEGRADED | ChannelDegradedError |
|
||
| PLUGIN_FAILED | PluginFailedError |
|
||
| PIPELINE_CONFIG_ERROR | PipelineConfigError |
|
||
| LIFECYCLE_HOOK_ERROR | LifecycleHookError |
|
||
| CONTENT_VIOLATION | ContentViolationError |
|
||
| BOT_LOOP_BUDGET_EXCEEDED | BotLoopBudgetExceededError |
|
||
| RATE_LIMIT | RateLimitError |
|
||
| PAIRING_EXPIRED | PairingExpiredError |
|
||
| DEPENDENCY | DependencyError |
|
||
| TIMEOUT | OperationTimeoutError |
|
||
| NOT_IMPLEMENTED | NotImplementedError |
|
||
| (其它) | InternalError |
|
||
+------------------------------+-------------------------------------+
|
||
|
||
Args:
|
||
result: 控制面端口方法返回的 ``ControlResult``。
|
||
|
||
Raises:
|
||
InternalError: ``status`` 非 ``success`` / ``partial`` 但 ``errors``
|
||
为空时抛出,表示控制管道失败但未携带错误详情。
|
||
ValidationError: ``error_code`` 为 ``VALIDATION_ERROR`` /
|
||
``CONFIG_VALIDATION``。
|
||
AuthError: ``error_code`` 为 ``AUTH_ERROR``。
|
||
NotFoundError: ``error_code`` 为 ``NOT_FOUND``。
|
||
PluginNotFoundError: ``error_code`` 为 ``PLUGIN_NOT_FOUND``。
|
||
PermissionDeniedError: ``error_code`` 为 ``PERMISSION_DENIED``。
|
||
DmDeniedError: ``error_code`` 为 ``DM_DENIED``。
|
||
ConflictError: ``error_code`` 为 ``CONFLICT`` /
|
||
``CONFIG_VERSION_CONFLICT`` / ``CONFIG_RESTART_REQUIRED``。
|
||
PluginAlreadyRegisteredError: ``error_code`` 为
|
||
``PLUGIN_ALREADY_REGISTERED``。
|
||
RuleViolationError: ``error_code`` 为 ``RULE_VIOLATION``。
|
||
CapabilityNotProvenError: ``error_code`` 为 ``CAPABILITY_NOT_PROVEN``。
|
||
ConfigNotHotReloadableError: ``error_code`` 为
|
||
``CONFIG_NOT_HOT_RELOADABLE``。
|
||
ConfigRollbackError: ``error_code`` 为 ``CONFIG_ROLLBACK_FAILED``。
|
||
SchemaInitializationError: ``error_code`` 为 ``SCHEMA_INIT_FAILED``。
|
||
ChannelDegradedError: ``error_code`` 为 ``CHANNEL_DEGRADED``
|
||
(HTTP 503,FR-36)。
|
||
PluginFailedError: ``error_code`` 为 ``PLUGIN_FAILED``。
|
||
PipelineConfigError: ``error_code`` 为 ``PIPELINE_CONFIG_ERROR``。
|
||
LifecycleHookError: ``error_code`` 为 ``LIFECYCLE_HOOK_ERROR``。
|
||
ContentViolationError: ``error_code`` 为 ``CONTENT_VIOLATION``
|
||
(预留,本期不入站 / 出站管道自动审核不触发)。
|
||
BotLoopBudgetExceededError: ``error_code`` 为
|
||
``BOT_LOOP_BUDGET_EXCEEDED``(HTTP 429,FR-33)。
|
||
RateLimitError: ``error_code`` 为 ``RATE_LIMIT``(HTTP 429,FR-19)。
|
||
PairingExpiredError: ``error_code`` 为 ``PAIRING_EXPIRED``
|
||
(HTTP 410,FR-33)。
|
||
DependencyError: ``error_code`` 为 ``DEPENDENCY``(HTTP 502)。
|
||
OperationTimeoutError: ``error_code`` 为 ``TIMEOUT``(HTTP 504)。
|
||
NotImplementedError: ``error_code`` 为 ``NOT_IMPLEMENTED``。
|
||
InternalError: 未命中上述映射的兜底异常。
|
||
"""
|
||
if result.status in ("success", "partial"):
|
||
# partial 表示批量操作部分成功,不视为失败,由 router 在响应 envelope
|
||
# 中通过 result.status 反映部分成功语义。
|
||
return
|
||
if not result.errors:
|
||
raise InternalError(message="control pipeline failed without error")
|
||
failure = result.errors[0]
|
||
message = failure.message
|
||
details = failure.details or {}
|
||
code = failure.error_code
|
||
if code in ("VALIDATION_ERROR", "CONFIG_VALIDATION"):
|
||
raise ValidationError(field=details.get("field", ""), message=message)
|
||
if code == "AUTH_ERROR":
|
||
raise AuthError(reason=details.get("reason", message))
|
||
if code == "NOT_FOUND":
|
||
raise NotFoundError(resource=details.get("resource", ""), id=details.get("id", ""))
|
||
if code == "PLUGIN_NOT_FOUND":
|
||
raise PluginNotFoundError(plugin_id=details.get("plugin_id", ""))
|
||
if code == "PERMISSION_DENIED":
|
||
raise PermissionDeniedError(reason=details.get("reason", message))
|
||
if code == "DM_DENIED":
|
||
raise DmDeniedError(reason=details.get("reason", message))
|
||
if code in ("CONFLICT", "CONFIG_VERSION_CONFLICT", "CONFIG_RESTART_REQUIRED"):
|
||
raise ConflictError(resource=details.get("resource", ""))
|
||
if code == "PLUGIN_ALREADY_REGISTERED":
|
||
raise PluginAlreadyRegisteredError(plugin_id=details.get("plugin_id", ""))
|
||
if code == "RULE_VIOLATION":
|
||
raise RuleViolationError(rule=details.get("rule", ""))
|
||
if code == "CAPABILITY_NOT_PROVEN":
|
||
raise CapabilityNotProvenError(capability=details.get("capability", ""))
|
||
if code == "CONFIG_NOT_HOT_RELOADABLE":
|
||
raise ConfigNotHotReloadableError(key=details.get("key", ""))
|
||
if code == "CONFIG_ROLLBACK_FAILED":
|
||
raise ConfigRollbackError(
|
||
key=details.get("key", ""),
|
||
reason=details.get("reason", message),
|
||
)
|
||
if code == "SCHEMA_INIT_FAILED":
|
||
raise SchemaInitializationError(reason=details.get("reason", message))
|
||
if code == "CHANNEL_DEGRADED":
|
||
raise ChannelDegradedError(channel=details.get("channel", ""))
|
||
if code == "PLUGIN_FAILED":
|
||
raise PluginFailedError(
|
||
plugin_id=details.get("plugin_id", ""),
|
||
reason=details.get("reason", message),
|
||
)
|
||
if code == "PIPELINE_CONFIG_ERROR":
|
||
raise PipelineConfigError(
|
||
pipeline=details.get("pipeline", ""),
|
||
stage_id=details.get("stage_id", ""),
|
||
compensate=details.get("compensate", ""),
|
||
)
|
||
if code == "LIFECYCLE_HOOK_ERROR":
|
||
raise LifecycleHookError(
|
||
hook=details.get("hook", ""),
|
||
reason=details.get("reason", message),
|
||
)
|
||
if code == "CONTENT_VIOLATION":
|
||
raise ContentViolationError(message)
|
||
if code == "BOT_LOOP_BUDGET_EXCEEDED":
|
||
raise BotLoopBudgetExceededError(budget=details.get("budget", {}) or {})
|
||
if code == "RATE_LIMIT":
|
||
raise RateLimitError(
|
||
resource=details.get("resource", ""),
|
||
retry_after_ms=details.get("retry_after_ms", 0),
|
||
)
|
||
if code == "PAIRING_EXPIRED":
|
||
raise PairingExpiredError(pairing_id=details.get("pairing_id", ""))
|
||
if code == "DEPENDENCY":
|
||
# 反向重构时原始 cause 已丢失,传入仅含 message 的合成 Exception
|
||
# 保留 dep 字段以维持 DependencyError 语义;cause 链路在 FailureDetail
|
||
# 中已结构化记录,可由日志侧查取。
|
||
raise DependencyError(
|
||
dep=details.get("dep", ""),
|
||
cause=Exception(message),
|
||
)
|
||
if code == "TIMEOUT":
|
||
raise OperationTimeoutError(
|
||
timeout_ms=details.get("timeout_ms", 0),
|
||
message=message,
|
||
)
|
||
if code == "NOT_IMPLEMENTED":
|
||
raise NotImplementedError(
|
||
operation=details.get("operation", ""),
|
||
message=message,
|
||
details={k: v for k, v in details.items() if k != "operation"},
|
||
)
|
||
raise InternalError(message=message)
|
||
|
||
|
||
def parse_datetime(field: str, raw: str | None) -> datetime | None:
|
||
"""ISO 8601 string → UTC datetime,失败抛 ValidationError。
|
||
|
||
作为 channels 各子 Router 共享的时间查询参数翻译函数,将 HTTP 查询参数
|
||
(string)翻译为 aware UTC ``datetime``,与各表时间列的 UTC 存储语义
|
||
对齐;``raw`` 为 ``None`` 时返回 ``None`` 表示不过滤。
|
||
|
||
naive 字符串按项目约定视为 Asia/Shanghai,aware 字符串统一转 UTC
|
||
(与 dispatch_stage ``_coerceQueryDateTime`` 同源,复用
|
||
``coerce_any_to_utc_datetime``);非法格式抛 ``ValidationError``(400),
|
||
由全局异常处理器映射,保留原始 traceback。
|
||
|
||
Args:
|
||
field: 字段名(用于错误消息定位,如 ``start_time`` / ``end_time``)。
|
||
raw: ISO 8601 字符串或 ``None``。
|
||
|
||
Returns:
|
||
UTC ``datetime`` 或 ``None``。
|
||
|
||
Raises:
|
||
ValidationError: ``raw`` 非 ``None`` 且无法解析为合法 datetime 时抛出。
|
||
"""
|
||
if raw is None:
|
||
return None
|
||
try:
|
||
return coerce_any_to_utc_datetime(raw)
|
||
except (ValueError, TypeError) as exc:
|
||
raise ValidationError(
|
||
field,
|
||
f"invalid {field} format: {raw} (expected ISO 8601)",
|
||
) from exc
|
||
|
||
|
||
def sanitize_headers(headers: Any) -> dict[str, str]:
|
||
"""脱敏 HTTP headers,剔除敏感字段(§11.1.2 日志不得记录敏感字段)。
|
||
|
||
保留签名字段(如 x-signature / x-timestamp)供入站管道校验。敏感字段
|
||
集合统一引用 ``yuxi.utils.crypto.SENSITIVE_HTTP_HEADERS``(单一事实源)。
|
||
"""
|
||
if headers is None:
|
||
return {}
|
||
return {key: value for key, value in headers.items() if key.lower() not in SENSITIVE_HTTP_HEADERS}
|
||
|
||
|
||
channels_router = APIRouter(prefix="/channels", tags=["channels"])
|
||
|
||
|
||
# 子 router 导入置于共享工具定义之后,避免循环导入
|
||
# 注册顺序:静态路径 router(如 health_router)必须先于含 /{channel_type}/...
|
||
# 动态路径的 router 注册,避免 /channels/health 被捕获为 channel_type="health"
|
||
# (规范 §6.5)。
|
||
from server.routers.channels.health_router import health_router # noqa: E402
|
||
|
||
channels_router.include_router(health_router)
|
||
|
||
from server.routers.channels.audit_router import audit_router # noqa: E402
|
||
|
||
channels_router.include_router(audit_router)
|
||
|
||
from server.routers.channels.analytics_router import analytics_router # noqa: E402
|
||
|
||
channels_router.include_router(analytics_router)
|
||
|
||
from server.routers.channels.account_router import account_router # noqa: E402
|
||
from server.routers.channels.directory_router import directory_router # noqa: E402
|
||
from server.routers.channels.login_router import login_router # noqa: E402
|
||
from server.routers.channels.pairing_router import pairing_router # noqa: E402
|
||
from server.routers.channels.session_router import session_router # noqa: E402
|
||
|
||
channels_router.include_router(account_router)
|
||
channels_router.include_router(session_router)
|
||
channels_router.include_router(login_router)
|
||
channels_router.include_router(directory_router)
|
||
channels_router.include_router(pairing_router)
|
||
|
||
from server.routers.channels.allowlist_router import allowlist_router # noqa: E402
|
||
|
||
channels_router.include_router(allowlist_router)
|
||
|
||
from server.routers.channels.message_router import message_router # noqa: E402
|
||
|
||
channels_router.include_router(message_router)
|
||
|
||
from server.routers.channels.config_router import config_router # noqa: E402
|
||
from server.routers.channels.outbox_router import outbox_router # noqa: E402
|
||
from server.routers.channels.webhook_router import webhook_router # noqa: E402
|
||
|
||
channels_router.include_router(outbox_router)
|
||
channels_router.include_router(webhook_router)
|
||
channels_router.include_router(config_router)
|
||
|
||
from server.routers.channels.capability_router import capability_router # noqa: E402
|
||
|
||
channels_router.include_router(capability_router)
|
||
|
||
from server.routers.channels.doctor_router import doctor_router # noqa: E402
|
||
|
||
channels_router.include_router(doctor_router)
|
||
|
||
from server.routers.channels.plugin_router import plugin_router # noqa: E402
|
||
|
||
channels_router.include_router(plugin_router)
|
||
|
||
from server.routers.channels.content_review_router import content_review_router # noqa: E402
|
||
|
||
channels_router.include_router(content_review_router)
|
||
|
||
from server.routers.channels.dashboard_router import dashboard_router # noqa: E402
|
||
|
||
channels_router.include_router(dashboard_router)
|
||
|
||
from server.routers.channels.wizard_router import wizard_router # noqa: E402
|
||
|
||
channels_router.include_router(wizard_router)
|
||
|
||
from server.routers.channels.route_binding_router import route_binding_router # noqa: E402
|
||
|
||
channels_router.include_router(route_binding_router)
|