ForcePilot/backend/package/yuxi/channels/application/transport/manager.py
Kris b8ac375e8e feat: 新增多渠道客服会话、身份合并与重试能力等功能
本次提交包含多项核心功能迭代与优化:
1. 新增KF客服会话类型,完善聊天类型枚举
2. 新增消息撤回操作类型与身份置信度排序方法
3. 新增控制面结果DTO与敏感字段注册表端口
4. 新增身份合并回滚、重试失败投递目标等业务能力
5. 优化Outbox投递逻辑与熔断器状态判断
6. 修复部分代码冗余与类型不匹配问题
7. 新增数据库索引并发创建与路由绑定清理逻辑
8. 优化会话关闭服务与插件重载并发控制
2026-07-09 04:21:28 +08:00

740 lines
28 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.

"""入站传输管理器。
统一管理 PullerWorker 和 StreamWorker 的生命周期,订阅账号上下线事件,
动态启动/停止账号的传输任务。
"""
from __future__ import annotations
import asyncio
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from yuxi.channels.application.transport.base_worker import (
TransportConfig,
)
from yuxi.channels.application.transport.puller_worker import PullerWorker
from yuxi.channels.application.transport.stream_worker import StreamWorker
from yuxi.channels.contract.dtos.channel import AccountFilter, AccountStatus, ChannelType
from yuxi.channels.contract.dtos.health import TransportHealthSnapshot
from yuxi.channels.contract.dtos.plugin import DomainEvent, EventHandler
from yuxi.channels.contract.plugin.extension_point import EventSubscription
from yuxi.channels.contract.plugin.manifest import FailurePolicy
from yuxi.channels.contract.ports.driven import (
ConfigPort,
LoggerPort,
PersistencePort,
)
from yuxi.channels.core.registry import PluginRegistry
__all__ = ["TransportManager"]
# 管理循环的轮询间隔(秒):仅用于等待取消信号,不参与业务逻辑
_MANAGER_LOOP_INTERVAL_S: float = 1.0
class TransportManager:
"""入站传输全局管理器。
单例模式管理 PullerWorker 和 StreamWorker订阅 ChannelAccountOnline/Offline
事件,根据 transport_mode 与适配器能力动态启动/停止对应账号的传输任务
Task 11pull 仅 Puller、stream 仅 Stream、both 优先 Stream 降级 Puller
职责:
- 从 PluginRegistry 动态构建 puller/stream 适配器注册表
- 创建并管理 PullerWorker、StreamWorker 单例
- 订阅账号上下线事件,驱动 Worker 启动/停止账号任务
- 提供全局健康状态查询
- 优雅关停所有传输任务
"""
def __init__(
self,
*,
plugin_registry: PluginRegistry,
persistence_port: PersistencePort,
config_port: ConfigPort,
event_bus: Any,
circuit_breaker: Any,
logger: LoggerPort,
message_deliverer: Callable[[Any], Awaitable[Any]],
transport_config: TransportConfig | None = None,
) -> None:
"""初始化 TransportManager。
参数:
plugin_registry: 插件注册表,用于动态获取渠道适配器。
persistence_port: 持久化端口,用于游标持久化等。
config_port: 配置端口。
event_bus: 事件总线,用于订阅/发布事件。
circuit_breaker: 熔断器实例。
logger: 日志端口。
message_deliverer: 入站消息投递回调。
transport_config: 传输配置,为 None 时使用默认配置。
"""
self._plugin_registry = plugin_registry
self._persistence_port = persistence_port
self._config_port = config_port
self._event_bus = event_bus
self._circuit_breaker = circuit_breaker
self._logger = logger
self._message_deliverer = message_deliverer
self._config = transport_config or TransportConfig()
self._puller_registry: dict[ChannelType, Any] = {}
self._stream_connector_registry: dict[ChannelType, Any] = {}
self._puller_worker: PullerWorker | None = None
self._stream_worker: StreamWorker | None = None
self._manager_task: asyncio.Task[None] | None = None
self._running: bool = False
self._plugin_id = "transport-manager"
# H-14恢复扫描与配置热更新互斥锁进程内防同进程竞态
self._restore_lock = asyncio.Lock()
async def start(self) -> None:
"""启动传输管理器。
- 构建 puller_registry 和 stream_connector_registry
- 创建 PullerWorker 和 StreamWorker 实例
- 启动 Worker
- 订阅账号上下线事件
- 启动管理循环任务
"""
if self._running:
return
trace_id = str(uuid.uuid4())
puller_registry: dict[ChannelType, Any] = {}
stream_connector_registry: dict[ChannelType, Any] = {}
for channel_type, plugin_da in self._plugin_registry.listPluginAdapters():
if plugin_da.puller_adapters:
puller_registry[channel_type] = plugin_da.puller_adapters[0]
if plugin_da.stream_connector_adapters:
stream_connector_registry[channel_type] = plugin_da.stream_connector_adapters[0]
self._puller_registry = puller_registry
self._stream_connector_registry = stream_connector_registry
self._puller_worker = PullerWorker(
message_deliverer=self._message_deliverer,
logger=self._logger,
config_port=self._config_port,
event_publisher=self._event_bus,
circuit_breaker=self._circuit_breaker,
persistence_port=self._persistence_port,
config=self._config,
)
self._stream_worker = StreamWorker(
message_deliverer=self._message_deliverer,
logger=self._logger,
config_port=self._config_port,
event_publisher=self._event_bus,
circuit_breaker=self._circuit_breaker,
config=self._config,
)
await self._puller_worker.start()
await self._stream_worker.start()
self._registerEventHandlers()
self._running = True
self._manager_task = asyncio.create_task(
self._manager_loop(),
name="transport-manager-loop",
)
await self._logger.info(
"transport manager started",
trace_id=trace_id,
puller_channels=[ct for ct in puller_registry.keys()],
stream_channels=[ct for ct in stream_connector_registry.keys()],
)
# 重启恢复:扫描 DB 中 ACTIVE 状态账号,为每个账号启动传输任务。
# 系统重启后不会收到 ChannelAccountOnline 事件,必须主动恢复,
# 否则已上线账号的传输任务SSE/短轮询)不会启动。
await self._restoreOnlineAccounts(trace_id)
async def stop(self, timeout: float = 5.0) -> None:
"""停止传输管理器。
- 取消事件订阅
- 取消管理循环任务
- 停止所有 Worker
- 等待所有任务完成
参数:
timeout: 等待 Worker 停止的超时时间(秒)。
"""
if not self._running:
return
trace_id = str(uuid.uuid4())
self._running = False
self._unregisterEventHandlers()
if self._manager_task is not None and not self._manager_task.done():
self._manager_task.cancel()
try:
await self._manager_task
except asyncio.CancelledError:
pass
self._manager_task = None
if self._puller_worker is not None:
await self._puller_worker.stop(timeout)
self._puller_worker = None
if self._stream_worker is not None:
await self._stream_worker.stop(timeout)
self._stream_worker = None
await self._logger.info(
"transport manager stopped",
trace_id=trace_id,
)
def getHealth(self) -> TransportHealthSnapshot:
"""返回传输引擎健康状态(同步,向后兼容)。
返回:
``TransportHealthSnapshot``,包含 running 状态和各 Worker 健康
子状态(``WorkerHealthSnapshot``)。
"""
return TransportHealthSnapshot(
running=self._running,
puller=self._puller_worker.getHealth() if self._puller_worker else None,
stream=self._stream_worker.getHealth() if self._stream_worker else None,
)
async def getTransportHealth(self) -> TransportHealthSnapshot:
"""返回传输引擎健康状态(实现 TransportHealthPortFR-18
供 ``HealthAggregator`` 通过 ``TransportHealthPort`` 聚合,暴露每个
账号的运行状态running/backoff/stopped/error。实现幂等、无副作用
不抛异常阻塞调用方。
返回:
``TransportHealthSnapshot``,结构见 ``TransportHealthPort`` 协议
文档。异常时降级返回 ``puller`` / ``stream`` 为 ``None`` 的快照。
"""
try:
return self.getHealth()
except Exception as exc:
# 健康检查不得抛异常阻塞调用方(与 HealthAggregator 超时降级策略一致)
await self._logger.warn(
"transport health query failed, returning empty state",
error=str(exc),
)
return TransportHealthSnapshot(
running=self._running,
puller=None,
stream=None,
)
async def _manager_loop(self) -> None:
"""管理循环,等待取消信号。"""
try:
while self._running:
await asyncio.sleep(_MANAGER_LOOP_INTERVAL_S)
except asyncio.CancelledError:
pass
def _registerEventHandlers(self) -> None:
"""注册事件处理器。"""
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelAccountOnline",
handler=_AccountEventHandler(self._on_account_online),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelAccountOffline",
handler=_AccountEventHandler(self._on_account_offline),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelDegraded",
handler=_AccountEventHandler(self._on_channel_degraded),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelRecovered",
handler=_AccountEventHandler(self._on_channel_recovered),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="TransportErrorOccurred",
handler=_AccountEventHandler(self._on_transport_error),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
# 配置热更新订阅FR-04transport.* 配置变更时应用 hot/restart 策略
for config_event in ("ConfigChanged", "ConfigRollback"):
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type=config_event,
handler=_AccountEventHandler(self._on_config_changed),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
def _unregisterEventHandlers(self) -> None:
"""取消事件订阅。"""
self._event_bus.unregister(self._plugin_id)
async def _on_account_online(self, event: DomainEvent) -> None:
"""处理账号上线事件。
从事件 payload 获取 channel_type 和 account_id根据 transport_mode
与适配器能力选择启动 Puller 或 Stream 任务Task 11.1 + 11.2
- ``pull``: 仅启动 Puller
- ``stream``: 仅启动 Stream
- ``both``: 优先 StreamPuller 作为降级Stream 适配器不可用时启动)
参数:
event: ChannelAccountOnline 事件。
"""
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
"account online event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
account_id=account_id,
)
return
channel_type = ChannelType(channel_type_raw)
await self._logger.info(
"received account online event",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
)
await self._startTransportForAccount(channel_type, account_id, trace_id, source="event")
async def _startTransportForAccount(
self,
channel_type: ChannelType,
account_id: str,
trace_id: str,
source: str = "event",
) -> None:
"""为单个账号启动传输任务(事件驱动与重启恢复共用)。
根据 transport_mode 与适配器能力选择启动 Puller 或 Stream 任务:
- ``pull``: 仅启动 Puller
- ``stream``: 仅启动 Stream
- ``both``: 优先 StreamPuller 作为降级Stream 适配器不可用时启动)
参数:
channel_type: 渠道类型。
account_id: 账号 ID。
trace_id: 链路追踪 ID。
source: 启动来源(``event`` 事件驱动 / ``restore`` 重启恢复),
仅用于日志区分。
"""
transport_mode = await self._resolveTransportMode(channel_type, account_id)
puller_adapter = self._puller_registry.get(channel_type)
stream_adapter = self._stream_connector_registry.get(channel_type)
await self._logger.info(
"starting transport for account",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
transport_mode=transport_mode,
has_puller_adapter=puller_adapter is not None,
has_stream_adapter=stream_adapter is not None,
source=source,
)
started = False
if transport_mode == "pull":
if puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
started = True
elif transport_mode == "stream":
if stream_adapter is not None and self._stream_worker is not None:
await self._stream_worker.start_account(channel_type, account_id, stream_adapter)
started = True
else:
# Task 11.2: both 模式优先 StreamPuller 作为降级。
# Stream 适配器可用时仅启动 StreamStream 健康时不 poll
# Stream 适配器不可用时降级启动 Puller。
if stream_adapter is not None and self._stream_worker is not None:
await self._stream_worker.start_account(channel_type, account_id, stream_adapter)
started = True
elif puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
started = True
# 传输任务成功启动后持久化插件运行态FR-32。诊断字段写入失败
# 仅告警不中止(与 last_health_check_at 一致的 best-effort 语义)。
if started:
await self._touchPluginStatus(channel_type, account_id, "running", trace_id)
async def _restoreOnlineAccounts(self, trace_id: str) -> None:
"""重启恢复:扫描 DB 中 ACTIVE 状态账号,启动传输任务。
系统重启后 TransportManager 不会收到 ChannelAccountOnline 事件,
本方法在 ``start()`` 中被调用,为所有已注册渠道的 ACTIVE 状态账号
启动传输任务SSE 或短轮询),实现重启后自动恢复。
错误隔离:单个账号查询/启动失败不阻塞其他账号,仅记录 warn 日志。
仅恢复注册表中有 puller 或 stream 适配器的渠道(未注册适配器的
渠道跳过)。
参数:
trace_id: 启动链路追踪 ID。
"""
# H-14持有恢复锁与 _on_config_changed 的 reloadConfig 互斥,
# 防止恢复扫描与配置热更新竞态。异常时由 async with 保证锁释放。
async with self._restore_lock:
restored = 0
failed = 0
# 合并 puller 和 stream 注册表的渠道类型,避免遗漏
channel_types = set(self._puller_registry.keys()) | set(self._stream_connector_registry.keys())
for channel_type in channel_types:
try:
accounts = await self._persistence_port.findAccountsByFilter(
AccountFilter(channel_type=channel_type, status=AccountStatus.ACTIVE)
)
except Exception as exc:
await self._logger.warn(
"transport restore: failed to query accounts for channel",
trace_id=trace_id,
channel_type=channel_type,
error=str(exc),
)
continue
for account in accounts:
try:
await self._startTransportForAccount(
channel_type=channel_type,
account_id=account.account_id,
trace_id=trace_id,
source="restore",
)
restored += 1
except Exception as exc:
failed += 1
await self._logger.warn(
"transport restore: failed to start account",
trace_id=trace_id,
channel_type=channel_type,
account_id=account.account_id,
error=str(exc),
)
await self._logger.info(
"transport restore completed",
trace_id=trace_id,
restored_accounts=restored,
failed_accounts=failed,
channel_count=len(channel_types),
)
async def _resolveTransportMode(
self,
channel_type: ChannelType,
account_id: str,
) -> str:
"""解析账号的传输模式Task 11.1)。
优先使用账号级 ``transport_mode````ChannelAccount.transport_mode``
账号不存在或读取失败时回退到 manifest 声明的渠道级 ``transport_mode``
均不可用时回退到 ``both``。
参数:
channel_type: 渠道类型。
account_id: 渠道账号ID。
返回:
传输模式字符串(``pull`` / ``stream`` / ``both``)。
"""
try:
account = await self._persistence_port.getChannelAccount(channel_type, account_id)
except Exception as exc:
await self._logger.warn(
"failed to get channel account for transport_mode resolution, fallback to manifest default",
channel_type=channel_type,
account_id=account_id,
error=str(exc),
)
account = None
if account is not None:
return account.transport_mode
plugin = self._plugin_registry.getPluginByChannelType(channel_type)
if plugin is not None:
return plugin.manifest.transport_mode
return "both"
async def _on_account_offline(self, event: DomainEvent) -> None:
"""处理账号下线事件。
停止对应账号的所有传输任务。
参数:
event: ChannelAccountOffline 事件。
"""
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
"account offline event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
account_id=account_id,
)
return
channel_type = ChannelType(channel_type_raw)
reason = event.payload.get("reason", "offline")
await self._logger.info(
"received account offline event, stopping transport",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)
if self._puller_worker is not None:
await self._puller_worker.stop_account(channel_type, account_id, reason)
if self._stream_worker is not None:
await self._stream_worker.stop_account(channel_type, account_id, reason)
# 传输任务停止后持久化插件运行态FR-32
await self._touchPluginStatus(channel_type, account_id, "stopped", trace_id)
async def _on_channel_degraded(self, event: DomainEvent) -> None:
"""处理渠道降级事件。
P0 阶段留空P1 阶段实现降级逻辑(如暂停非关键任务、调整轮询间隔等)。
参数:
event: ChannelDegraded 事件。
"""
pass
async def _on_channel_recovered(self, event: DomainEvent) -> None:
"""处理渠道恢复事件。
P0 阶段留空P1 阶段实现恢复逻辑。
参数:
event: ChannelRecovered 事件。
"""
pass
async def _on_transport_error(self, event: DomainEvent) -> None:
"""处理传输错误事件。
记录日志并将账户插件运行态置为 ``error``FR-32。P1 阶段可用于
监控告警。
参数:
event: TransportErrorOccurred 事件。
"""
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
await self._logger.warn(
"transport error occurred",
trace_id=trace_id,
channel_type=channel_type_raw,
account_id=account_id,
error_category=event.payload.get("error_category"),
error_code=event.payload.get("error_code"),
)
# 持久化插件运行态为 errorFR-32。仅当事件携带账户信息时写入。
if channel_type_raw is not None and account_id is not None:
await self._touchPluginStatus(ChannelType(channel_type_raw), account_id, "error", trace_id)
async def _touchPluginStatus(
self,
channel_type: ChannelType,
account_id: str,
plugin_status: str,
trace_id: str,
) -> None:
"""更新账户插件运行态FR-32
传输任务 start/stop/error 后调用 ``PersistencePort.updatePluginStatus``
持久化插件运行态running / stopped / error。诊断字段best-effort
写入:失败时仅记录告警,不中止传输主流程(与 ``last_health_check_at``
一致)。
"""
try:
await self._persistence_port.updatePluginStatus(channel_type, account_id, plugin_status)
except Exception as exc:
await self._logger.warn(
"failed to update plugin_status",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
plugin_status=plugin_status,
error=str(exc),
)
async def _on_config_changed(self, event: DomainEvent) -> None:
"""处理配置变更事件FR-04 配置热更新)。
监听 ``transport.*`` 前缀的全局配置变更,按 hot/restart 模式应用:
- hot 模式(``stall_timeout_ms`` / ``backoff_schedule`` /
``backoff_jitter``):重新加载 TransportConfig 并更新 Worker 配置,
当前轮次结束后生效。
- restart 模式(``max_restart_attempts`` /
``graceful_shutdown_timeout_s``):记录警告日志,需手动重启
TransportManager 生效P1 阶段不实现自动重启,避免账号连接中断)。
参数:
event: ConfigChanged / ConfigRollback 事件payload 包含 key。
"""
key = event.payload.get("key", "")
if not key.startswith("transport."):
return
# 仅处理全局配置target 为 None 或空),忽略账户级配置
target = event.payload.get("target")
if target:
return
trace_id = event.trace_id or str(uuid.uuid4())
if key in _RESTART_REQUIRED_CONFIG_KEYS:
await self._logger.warn(
"transport config change requires restart to take effect",
trace_id=trace_id,
key=key,
event_type=event.event_type,
)
return
# 仅对 hot-reloadable 键触发重新加载,忽略未知 transport.* 键
if key not in _RELOADABLE_CONFIG_KEYS:
return
# hot 模式:重新加载配置并更新 Worker
await self._logger.info(
"transport config hot reload triggered",
trace_id=trace_id,
key=key,
event_type=event.event_type,
)
# H-14与 _restoreOnlineAccounts 互斥,等待恢复扫描完成后再重载配置
async with self._restore_lock:
await self.reloadConfig()
async def reloadConfig(self) -> None:
"""重新加载传输配置并更新 WorkerFR-04 hot 模式)。
从 ``ConfigPort`` 读取最新 ``transport.*`` 配置,更新
``TransportManager._config`` 及两个 Worker 的 ``_config``。
Worker 在下一轮循环(退避等待、轮询间隔、心跳间隔)时自动应用新配置。
幂等:多次调用安全,仅更新配置不重启任务。
"""
trace_id = str(uuid.uuid4())
try:
new_config = await self._loadTransportConfig()
except Exception as exc:
await self._logger.error(
"transport config reload failed, keeping old config",
trace_id=trace_id,
error=str(exc),
)
return
self._config = new_config
if self._puller_worker is not None:
self._puller_worker._config = new_config
if self._stream_worker is not None:
self._stream_worker._config = new_config
await self._logger.info(
"transport config reloaded",
trace_id=trace_id,
stall_timeout_ms=new_config.stall_timeout_ms,
backoff_schedule=list(new_config.backoff_schedule),
backoff_jitter=new_config.backoff_jitter,
)
async def _loadTransportConfig(self) -> TransportConfig:
"""从 ConfigPort 加载 TransportConfig。
复用 BaseTransportWorker._loadConfig 的读取逻辑,集中配置加载
避免逻辑重复。
"""
# 委托任一 Worker 的 _loadConfig两者配置相同无 Worker 时
# 直接构造默认配置
if self._puller_worker is not None:
return await self._puller_worker._loadConfig()
if self._stream_worker is not None:
return await self._stream_worker._loadConfig()
return TransportConfig()
# transport.* 配置键的热更新模式分类FR-04
_RELOADABLE_CONFIG_KEYS: frozenset[str] = frozenset(
{
"transport.stall_timeout_ms",
"transport.backoff_schedule",
"transport.backoff_jitter",
"transport.stream_reconnect_backoff_ms",
"transport.stream_reconnect_max_backoff_ms",
}
)
_RESTART_REQUIRED_CONFIG_KEYS: frozenset[str] = frozenset(
{
"transport.max_restart_attempts",
"transport.graceful_shutdown_timeout_s",
}
)
class _AccountEventHandler(EventHandler):
"""账号事件处理器适配器。
将 EventHandler 接口适配到 TransportManager 的异步处理方法。
"""
def __init__(self, handler: Callable[[DomainEvent], Awaitable[None]]) -> None:
self._handler = handler
async def handle(self, event: DomainEvent) -> None:
await self._handler(event)