ForcePilot/backend/server/routers/channels/__init__.py
Kris e5e9f45411 refactor(channel-routers): 批量优化各渠道路由代码与契约对齐
1. config_router: 为expected_version添加ge=1校验
2. directory_router: 补充scope校验逻辑与注释
3. login_router: 拆分强制下线权限,添加参数校验与注释更新
4. reports_router: 统一时间参数处理,修复分页限制使用契约常量
5. dashboard_router: 更新文档与响应格式,修正参数传递逻辑
6. health_router: 缩减健康检查响应字段,修复响应结构与参数校验
7. plugin_router: 新增插件目录端点,补充枚举校验与注释
8. pairing_router: 新增时间过滤参数,补充参数校验
9. __init__.py: 修复异常映射,更新trace_id获取逻辑与工具类
10. doctor_router: 重构单项检查端点,修正注释与校验逻辑
11. account_router: 新增恢复降级账户端点,补充批量操作校验
12. webhook_router: 优化webhook处理逻辑,修复流式读取与响应逻辑
13. content_review_router: 补充批量审核端点,完善参数校验与注释
14. analytics_router: 修正管道阶段描述,统一参数传递
15. wizard_router: 新增OAuth相关端点,重构路由路径与校验逻辑
2026-07-04 00:16:00 +08:00

476 lines
20 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,
PairingExpiredError,
PermissionDeniedError,
PipelineConfigError,
PluginAlreadyRegisteredError,
PluginFailedError,
PluginNotFoundError,
RateLimitError,
RuleViolationError,
SchemaInitializationError,
OperationTimeoutError,
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)
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
channels_router.include_router(health_router)
from server.routers.channels.audit_router import audit_router
channels_router.include_router(audit_router)
from server.routers.channels.analytics_router import analytics_router
channels_router.include_router(analytics_router)
from server.routers.channels.account_router import account_router
from server.routers.channels.session_router import session_router
from server.routers.channels.login_router import login_router
from server.routers.channels.directory_router import directory_router
from server.routers.channels.pairing_router import pairing_router
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
channels_router.include_router(allowlist_router)
from server.routers.channels.message_router import message_router
channels_router.include_router(message_router)
from server.routers.channels.outbox_router import outbox_router
from server.routers.channels.webhook_router import webhook_router
from server.routers.channels.config_router import config_router
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
channels_router.include_router(capability_router)
from server.routers.channels.doctor_router import doctor_router
channels_router.include_router(doctor_router)
from server.routers.channels.plugin_router import plugin_router
channels_router.include_router(plugin_router)
from server.routers.channels.content_review_router import content_review_router
channels_router.include_router(content_review_router)
from server.routers.channels.dashboard_router import dashboard_router
channels_router.include_router(dashboard_router)
from server.routers.channels.reports_router import reports_router
channels_router.include_router(reports_router)
from server.routers.channels.wizard_router import wizard_router
channels_router.include_router(wizard_router)