ForcePilot/backend/server/routers/channels/__init__.py
Kris bba1775220 refactor(channel-router): 清理冗余空行并优化代码结构
本次提交包含多类优化:
1.  移除多个路由文件中多余的空导入行,统一代码格式
2.  重构Query参数定义,将长参数拆分为多行提升可读性
3.  新增多个业务端点:
    - 渠道能力画像矩阵查询CAP-03
    - 配对审批计数接口用于待办角标
    - 批量查询对端目录资料接口
    - 向导扫码登录相关端点
    - 会话实时事件SSE推送端点
    - 工作台待办统计接口
4.  完善异常处理逻辑,补充OperationTimeoutError导入并优化NotImplementedError的细节返回
5.  调整路由导入顺序,修复动态路由路径冲突隐患
6.  更新文档注释与接口清单,修正部分接口描述细节
2026-07-06 20:50:03 +08:00

479 lines
21 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.

"""channels 模块 Router 共享层。
为 channels 资源域的各子 Routersession / 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 503FR-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 429FR-33
RateLimitError: ``error_code`` 为 ``RATE_LIMIT``HTTP 429FR-19
PairingExpiredError: ``error_code`` 为 ``PAIRING_EXPIRED``
HTTP 410FR-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/Shanghaiaware 字符串统一转 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)