215 lines
6.7 KiB
Python
215 lines
6.7 KiB
Python
"""渠道管理端业务编排服务。
|
|
|
|
将 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()}
|