"""渠道管理端业务编排服务。 将 enable / test / restart 等跨 Gateway、ConfigManager、LifecycleManager 的编排逻辑从路由层下沉到此服务,保持路由层薄。 """ from __future__ import annotations import asyncio import time from typing import TYPE_CHECKING, Any from yuxi.channel.config import ChannelConfigManager from yuxi.channel.plugins.protocol import ChannelHealthStatus from yuxi.channel.transport.qr_login import LoginState from yuxi.channel.transport.qr_login_transport import BaseQRLoginTransport from yuxi.utils.logging_config import logger if TYPE_CHECKING: from yuxi.channel.lifecycle.manager import ChannelLifecycleManager from yuxi.channel.message.dispatcher import ChannelGateway class ChannelHealthCheckError(Exception): """渠道健康检查超时或失败。""" async def wait_for_channel_health( gateway: ChannelGateway, channel_type: str, account_id: str, timeout: float = 10.0, interval: float = 0.5, ) -> ChannelHealthStatus | None: """轮询等待渠道进入健康状态,超时或一直不健康则返回 None。""" start = time.monotonic() while True: try: health = await gateway.health_check(channel_type, account_id) if health.healthy: return health except Exception: pass elapsed = time.monotonic() - start remaining = timeout - elapsed if remaining <= 0: break await asyncio.sleep(min(interval, remaining)) return None async def enable_channel( channel_type: str, account_id: str, updated_by: str, gateway: ChannelGateway, lifecycle_manager: ChannelLifecycleManager, ) -> dict[str, Any]: """启用渠道账户,包含健康检查与回滚逻辑。 Returns: 包含 enabled、health、awaiting_login 字段的字典。 Raises: ChannelHealthCheckError: 健康检查超时,已自动回滚。 """ config_manager = ChannelConfigManager() await config_manager.update_config( channel_type=channel_type, account_id=account_id, enabled=True, updated_by=updated_by, ) health = await wait_for_channel_health(gateway, channel_type, account_id) # 检查是否为 QR 登录渠道且尚未登录 transport = lifecycle_manager.get_transport(channel_type, account_id) if isinstance(transport, BaseQRLoginTransport) and transport.login_state != LoginState.LOGGED_IN: health = health or await gateway.health_check(channel_type, account_id) return {"enabled": True, "health": health.to_dict(), "awaiting_login": True} if health is not None: return {"enabled": True, "health": health.to_dict()} # 健康检查超时,回滚 logger.error("Channel %s/%s enabled but health check failed: timeout", channel_type, account_id) try: await config_manager.update_config( channel_type=channel_type, account_id=account_id, enabled=False, updated_by=updated_by, ) except Exception as rollback_exc: logger.error( "Failed to rollback channel enabled state after startup failure for %s/%s: %s", channel_type, account_id, rollback_exc, ) try: await gateway.stop_channel(channel_type, account_id) except Exception as stop_exc: logger.warning( "Failed to stop channel %s/%s after startup failure: %s", channel_type, account_id, stop_exc, ) raise ChannelHealthCheckError("Channel enabled but health check failed") async def test_channel_config( channel_type: str, account_id: str, ) -> dict[str, Any]: """测试渠道账户配置连通性;不修改 enabled 状态。 Returns: 包含 success、checks、error 字段的字典。 """ from yuxi.channel.plugins.registry import get_registry registry = get_registry() plugin = registry.get_plugin(channel_type) if plugin is None: raise ValueError(f"Unknown channel type: {channel_type}") config_manager = ChannelConfigManager() config = await config_manager.get_config(channel_type, account_id) checks: dict[str, str] = { "config_schema": "skipped", "plugin_validate": "skipped", "auth": "skipped", } success = True error: str | None = None try: config_manager.validate_config_json(channel_type, config.get("config_json", {})) checks["config_schema"] = "ok" except Exception as exc: success = False checks["config_schema"] = "failed" error = str(exc) if success: try: ok, errors = plugin.validate_config(config.get("config_json", {})) if ok: checks["plugin_validate"] = "ok" else: success = False checks["plugin_validate"] = "failed" error = f"Config validation failed: {'; '.join(errors)}" except Exception as exc: success = False checks["plugin_validate"] = "failed" error = str(exc) if success: try: health = await plugin.health_check(config, account_id) if health.healthy: checks["auth"] = "ok" else: success = False checks["auth"] = "failed" error = health.last_error or "Health check failed" except Exception as exc: success = False checks["auth"] = "failed" error = str(exc) return {"success": success, "checks": checks, "error": error} async def restart_channel( channel_type: str, account_id: str, gateway: ChannelGateway, ) -> dict[str, Any]: """重启渠道账户。 Returns: 包含 restarted、health 字段的字典。 Raises: ChannelHealthCheckError: 重启后健康检查超时。 """ config_manager = ChannelConfigManager() config = await config_manager.get_config(channel_type, account_id) if not config.get("enabled"): raise ValueError("Channel is not enabled") try: await gateway.stop_channel(channel_type, account_id) except Exception as exc: logger.warning("Failed to stop channel %s/%s before restart: %s", channel_type, account_id, exc) try: await gateway.start_channel(channel_type, account_id) except Exception as exc: logger.warning("Failed to start channel %s/%s during restart: %s", channel_type, account_id, exc) health = await wait_for_channel_health(gateway, channel_type, account_id) if health is None: raise ChannelHealthCheckError("Channel restart failed: health check timeout") return {"restarted": True, "health": health.to_dict()}