ForcePilot/backend/server/routers/channels/__init__.py
Kris 12ec5af0de feat: 新增全链路追踪、优雅关停支持与统一异常处理,优化渠道查询接口
1. 新增TraceId中间件实现全链路请求追踪
2. 新增在途请求计数器与中间件支持优雅关停
3. 重构全局异常处理器,统一三模块异常响应格式
4. 优化渠道查询接口,新增多维度过滤与模糊匹配
5. 重构channels模块数据库会话管理,避免事务冲突
6. 新增目录搜索建议与目录导出功能
2026-07-07 16:25:47 +08:00

483 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 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.manager import pg_manager
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
# 时间粒度查询参数类型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(
di_container: DependencyInjectionContainer = Depends(get_channel_di_container),
) -> ChannelUseCases:
"""装配 channels 控制面 UseCase 集合(请求级独立 session
channels 模块 **不复用** 鉴权依赖(``get_db``)持有的 ``AsyncSession``
而是通过 ``pg_manager.get_async_session_context()`` 开启独立的请求级
会话。原因:鉴权阶段执行的 ``SELECT`` 会触发 SQLAlchemy 2.0 autobegin
语义开启隐式事务,若 channels 复用同一 session控制面
``TransactionPort.begin()`` 会在已存在的事务上再次 ``begin()``
抛出 ``InvalidRequestError: A transaction is already begun``。
独立 session 使事务边界由 channels 应用层独占控制,与鉴权会话隔离,
避免事务状态互相污染。会话生命周期限制在本次请求内,请求结束自动
commit无异常或 rollback有异常后关闭。
Args:
di_container: channels 模块 DI 容器。
Yields:
已装配的 ``ChannelUseCases``,含 ``session_management`` 等端口字段。
"""
async with pg_manager.get_async_session_context() as db:
yield 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)