ForcePilot/backend/server/routers/channels/__init__.py
Kris 2f6a4c29bb refactor(channel routers): 统一参数校验与常量定义,新增功能端点
1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用
2. 新增扫码登录向导端点,完善文档说明
3. 优化配对统计接口,移除无效参数
4. 为出站箱接口添加批量上限与202状态码
5. 新增测试用例、访问规则、配额等模块的查询与校验参数
6. 新增适配器健康批量查询、健康检查触发接口
7. 统一告警、审计日志的错误处理方式
8. 新增插件配置账户ID支持,优化批量操作响应
9. 新增环境健康批量查询、Webhook限流与参数校验
10. 完善会话管理、审计日志的参数与文档说明
11. 修复导入模块的校验错误处理逻辑
2026-07-11 21:39:05 +08:00

528 lines
24 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, Query, 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"]
# analytics 共享常量(从 DTO 层单一事实源引用,避免多处定义不同步)
from yuxi.channels.contract.dtos.analytics import ( # noqa: E402
DEFAULT_LIMIT as ANALYTICS_DEFAULT_LIMIT,
MAX_LIMIT as ANALYTICS_MAX_LIMIT,
MIN_LIMIT as ANALYTICS_MIN_LIMIT,
)
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
# ------------------------------------------------------------------
# 统一分页参数预设
#
# channels 模块所有列表类接口共享以下分页档位,消除参数声明散乱、默认值
# 不一、上限阈值随意等问题。各预设按业务语义分档:
#
# - DEFAULT_LIMIT : 高频翻页场景(审核 / 搜索 / 目录 / 路由绑定)
# - LARGE_LIMIT : 列表查询场景(会话 / 配对 / 白名单 / 审计)
# - MESSAGE_LIMIT : 消息历史场景(单会话消息流,上限 200 防止全量加载)
# - ANALYTICS_LIMIT : 分析 Top N 场景
# - EXPORT_LIMIT : 导出场景(参数名建议用 export_limit 以区分语义)
# - SUGGESTION_LIMIT: 输入联想场景
# - OFFSET : 分页偏移(与 DEFAULT_LIMIT / LARGE_LIMIT 搭配)
# - CURSOR : 游标分页标记(与 DEFAULT_LIMIT 搭配,用于 directory 系列)
#
# 使用方式:在接口签名中以模块级 Query 对象作为默认值——
# async def list_xxx(limit: int = DEFAULT_LIMIT, offset: int = OFFSET, ...):
# ------------------------------------------------------------------
DEFAULT_LIMIT: int = Query(default=20, ge=1, le=100, description="分页大小1-100默认 20")
LARGE_LIMIT: int = Query(default=100, ge=1, le=1000, description="分页大小1-1000默认 100")
MESSAGE_LIMIT: int = Query(default=50, ge=1, le=200, description="消息分页大小1-200默认 50")
ANALYTICS_LIMIT: int = Query(
default=ANALYTICS_DEFAULT_LIMIT,
ge=ANALYTICS_MIN_LIMIT,
le=ANALYTICS_MAX_LIMIT,
description="Top N 上限1-500默认 100",
)
EXPORT_LIMIT: int = Query(
default=10000,
ge=1,
le=100000,
description="导出条目上限1-100000默认 10000",
)
SUGGESTION_LIMIT: int = Query(default=10, ge=1, le=20, description="建议数量1-20默认 10")
OFFSET: int = Query(default=0, ge=0, description="分页偏移(>=0默认 0")
CURSOR: str | None = Query(default=None, description="游标分页标记")
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)