ForcePilot/backend/package/yuxi/services/channel_admin_service.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

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()}