506 lines
17 KiB
Python
506 lines
17 KiB
Python
|
|
# =============================================================================
|
||
|
|
# IT服务台健康检查服务 - 业务监控与自愈
|
||
|
|
# =============================================================================
|
||
|
|
# 功能:
|
||
|
|
# 1. 基础设施检查:nginx/backend/redis/postgres
|
||
|
|
# 2. 业务检查:坐席API/消息发送/企微回调/AI-RAG/WebSocket
|
||
|
|
# 3. 影响级别评估
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import logging
|
||
|
|
import time
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from enum import Enum
|
||
|
|
from typing import Any, Dict, List, Optional
|
||
|
|
|
||
|
|
import aiohttp
|
||
|
|
import redis.asyncio as aioredis
|
||
|
|
from sqlalchemy import text
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.config import settings
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class HealthStatus(str, Enum):
|
||
|
|
"""健康状态枚举"""
|
||
|
|
HEALTHY = "healthy"
|
||
|
|
DEGRADED = "degraded"
|
||
|
|
UNHEALTHY = "unhealthy"
|
||
|
|
UNKNOWN = "unknown"
|
||
|
|
|
||
|
|
|
||
|
|
class ImpactLevel(str, Enum):
|
||
|
|
"""影响级别"""
|
||
|
|
P0 = "P0" # 紧急 - 核心业务完全不可用
|
||
|
|
P1 = "P1" # 高 - 部分功能受损
|
||
|
|
P2 = "P2" # 中 - 非核心功能异常
|
||
|
|
P3 = "P3" # 低 - 轻微异常
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class CheckResult:
|
||
|
|
"""单项检查结果"""
|
||
|
|
name: str
|
||
|
|
status: HealthStatus
|
||
|
|
latency_ms: Optional[int] = None
|
||
|
|
error: Optional[str] = None
|
||
|
|
details: Optional[Dict[str, Any]] = None
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class HealthCheckReport:
|
||
|
|
"""健康检查报告"""
|
||
|
|
timestamp: str
|
||
|
|
overall_status: HealthStatus
|
||
|
|
overall_impact: ImpactLevel
|
||
|
|
infrastructure: Dict[str, CheckResult]
|
||
|
|
business: Dict[str, CheckResult]
|
||
|
|
errors: List[Dict[str, Any]]
|
||
|
|
|
||
|
|
|
||
|
|
class HealthCheckService:
|
||
|
|
"""健康检查服务"""
|
||
|
|
|
||
|
|
# 超时配置
|
||
|
|
TIMEOUT_SHORT = 2 # 2秒 - 基础设施
|
||
|
|
TIMEOUT_MEDIUM = 5 # 5秒 - 业务API
|
||
|
|
TIMEOUT_LONG = 15 # 15秒 - AI服务
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self._redis_client: Optional[aioredis.Redis] = None
|
||
|
|
|
||
|
|
async def get_redis(self) -> aioredis.Redis:
|
||
|
|
"""获取 Redis 客户端"""
|
||
|
|
if self._redis_client is None:
|
||
|
|
self._redis_client = settings.create_redis_client()
|
||
|
|
return self._redis_client
|
||
|
|
|
||
|
|
async def close(self):
|
||
|
|
"""关闭连接"""
|
||
|
|
if self._redis_client:
|
||
|
|
await self._redis_client.close()
|
||
|
|
self._redis_client = None
|
||
|
|
|
||
|
|
# -------------------------------------------------------------------------
|
||
|
|
# 基础设施检查
|
||
|
|
# -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
async def check_backend(self) -> CheckResult:
|
||
|
|
"""检查后端自身健康状态"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
# 直接检查自身健康端点
|
||
|
|
async with aiohttp.ClientSession() as session:
|
||
|
|
async with session.get(
|
||
|
|
f"http://localhost:8000/health",
|
||
|
|
timeout=aiohttp.ClientTimeout(total=self.TIMEOUT_SHORT)
|
||
|
|
) as resp:
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
if resp.status == 200:
|
||
|
|
return CheckResult(
|
||
|
|
name="backend",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
return CheckResult(
|
||
|
|
name="backend",
|
||
|
|
status=HealthStatus.DEGRADED,
|
||
|
|
latency_ms=latency,
|
||
|
|
error=f"HTTP {resp.status}"
|
||
|
|
)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
return CheckResult(
|
||
|
|
name="backend",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error="超时"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
return CheckResult(
|
||
|
|
name="backend",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
async def check_redis(self) -> CheckResult:
|
||
|
|
"""检查 Redis 连接"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
redis_client = await self.get_redis()
|
||
|
|
await redis_client.ping()
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
return CheckResult(
|
||
|
|
name="redis",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency
|
||
|
|
)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
return CheckResult(
|
||
|
|
name="redis",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error="连接超时"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
return CheckResult(
|
||
|
|
name="redis",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
async def check_postgres(self, db: AsyncSession) -> CheckResult:
|
||
|
|
"""检查 PostgreSQL 连接"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
await db.execute(text("SELECT 1"))
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
return CheckResult(
|
||
|
|
name="postgres",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
return CheckResult(
|
||
|
|
name="postgres",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
async def check_nginx(self) -> CheckResult:
|
||
|
|
"""检查 Nginx(通过本地代理)"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
async with aiohttp.ClientSession() as session:
|
||
|
|
async with session.get(
|
||
|
|
"http://localhost:80/health",
|
||
|
|
timeout=aiohttp.ClientTimeout(total=self.TIMEOUT_SHORT)
|
||
|
|
) as resp:
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
if resp.status < 500:
|
||
|
|
return CheckResult(
|
||
|
|
name="nginx",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
return CheckResult(
|
||
|
|
name="nginx",
|
||
|
|
status=HealthStatus.DEGRADED,
|
||
|
|
latency_ms=latency,
|
||
|
|
error=f"HTTP {resp.status}"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
# Nginx 检查失败不阻塞,返回 unknown
|
||
|
|
return CheckResult(
|
||
|
|
name="nginx",
|
||
|
|
status=HealthStatus.UNKNOWN,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
# -------------------------------------------------------------------------
|
||
|
|
# 业务检查
|
||
|
|
# -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
async def check_agents_api(self, db: AsyncSession) -> CheckResult:
|
||
|
|
"""检查坐席列表API"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
result = await db.execute(text("SELECT COUNT(*) FROM agents"))
|
||
|
|
count = result.scalar()
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
return CheckResult(
|
||
|
|
name="agents_api",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency,
|
||
|
|
details={"agent_count": count}
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
return CheckResult(
|
||
|
|
name="agents_api",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
async def check_message_send(self, db: AsyncSession) -> CheckResult:
|
||
|
|
"""检查消息发送能力(通过最近消息)"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
# 检查最近一条消息的状态
|
||
|
|
result = await db.execute(
|
||
|
|
text("SELECT created_at FROM messages ORDER BY created_at DESC LIMIT 1")
|
||
|
|
)
|
||
|
|
row = result.fetchone()
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
|
||
|
|
if row:
|
||
|
|
return CheckResult(
|
||
|
|
name="message_send",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency,
|
||
|
|
details={"last_message_at": str(row[0])}
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
# 无消息记录,但表可用
|
||
|
|
return CheckResult(
|
||
|
|
name="message_send",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency,
|
||
|
|
details={"last_message_at": None}
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
return CheckResult(
|
||
|
|
name="message_send",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
async def check_wecom_callback(self) -> CheckResult:
|
||
|
|
"""检查企微回调(通过企微服务)"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
from app.services.wecom_service import WecomService
|
||
|
|
wecom = WecomService(await self.get_redis())
|
||
|
|
# 尝试获取企微token
|
||
|
|
token = await wecom.get_access_token()
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
|
||
|
|
if token:
|
||
|
|
return CheckResult(
|
||
|
|
name="wecom_callback",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
return CheckResult(
|
||
|
|
name="wecom_callback",
|
||
|
|
status=HealthStatus.DEGRADED,
|
||
|
|
latency_ms=latency,
|
||
|
|
error="无法获取access_token"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
return CheckResult(
|
||
|
|
name="wecom_callback",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
async def check_ai_rag(self) -> CheckResult:
|
||
|
|
"""检查AI/RAG服务"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
# 尝试连接 RAGFlow
|
||
|
|
from app.config import settings
|
||
|
|
rag_url = settings.ragflow_url if hasattr(settings, 'ragflow_url') else "http://10.80.0.85:9380"
|
||
|
|
|
||
|
|
async with aiohttp.ClientSession() as session:
|
||
|
|
async with session.get(
|
||
|
|
f"{rag_url}/health",
|
||
|
|
timeout=aiohttp.ClientTimeout(total=self.TIMEOUT_LONG)
|
||
|
|
) as resp:
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
if resp.status == 200:
|
||
|
|
return CheckResult(
|
||
|
|
name="ai_rag",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
return CheckResult(
|
||
|
|
name="ai_rag",
|
||
|
|
status=HealthStatus.DEGRADED,
|
||
|
|
latency_ms=latency,
|
||
|
|
error=f"HTTP {resp.status}"
|
||
|
|
)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
return CheckResult(
|
||
|
|
name="ai_rag",
|
||
|
|
status=HealthStatus.UNHEALTHY,
|
||
|
|
error="连接超时"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
# RAG 服务不可用不阻塞业务,返回 degraded
|
||
|
|
return CheckResult(
|
||
|
|
name="ai_rag",
|
||
|
|
status=HealthStatus.DEGRADED,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
async def check_websocket(self) -> CheckResult:
|
||
|
|
"""检查WebSocket连接状态"""
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
# 通过 Redis 获取活跃连接数
|
||
|
|
redis_client = await self.get_redis()
|
||
|
|
keys = await redis_client.keys("ws:session:*")
|
||
|
|
latency = int((time.time() - start) * 1000)
|
||
|
|
|
||
|
|
return CheckResult(
|
||
|
|
name="websocket",
|
||
|
|
status=HealthStatus.HEALTHY,
|
||
|
|
latency_ms=latency,
|
||
|
|
details={"active_connections": len(keys)}
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
return CheckResult(
|
||
|
|
name="websocket",
|
||
|
|
status=HealthStatus.UNKNOWN,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
# -------------------------------------------------------------------------
|
||
|
|
# 综合检查
|
||
|
|
# -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
async def check_all(self, db: Optional[AsyncSession] = None) -> HealthCheckReport:
|
||
|
|
"""执行全量健康检查"""
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
# 基础设施检查(并行)
|
||
|
|
infra_tasks = {
|
||
|
|
"backend": self.check_backend(),
|
||
|
|
"redis": self.check_redis(),
|
||
|
|
"nginx": self.check_nginx(),
|
||
|
|
}
|
||
|
|
|
||
|
|
# 如果有 DB,检查数据库
|
||
|
|
if db:
|
||
|
|
infra_tasks["postgres"] = self.check_postgres(db)
|
||
|
|
|
||
|
|
infra_results = await asyncio.gather(
|
||
|
|
*[task for task in infra_tasks.values()],
|
||
|
|
return_exceptions=True
|
||
|
|
)
|
||
|
|
|
||
|
|
infrastructure = {}
|
||
|
|
for key, result in zip(infra_tasks.keys(), infra_results):
|
||
|
|
if isinstance(result, Exception):
|
||
|
|
infrastructure[key] = CheckResult(
|
||
|
|
name=key,
|
||
|
|
status=HealthStatus.UNKNOWN,
|
||
|
|
error=str(result)
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
infrastructure[key] = result
|
||
|
|
|
||
|
|
# 业务检查(并行)
|
||
|
|
business_tasks = {}
|
||
|
|
if db:
|
||
|
|
business_tasks["agents_api"] = self.check_agents_api(db)
|
||
|
|
business_tasks["message_send"] = self.check_message_send(db)
|
||
|
|
|
||
|
|
business_tasks["wecom_callback"] = self.check_wecom_callback()
|
||
|
|
business_tasks["ai_rag"] = self.check_ai_rag()
|
||
|
|
business_tasks["websocket"] = self.check_websocket()
|
||
|
|
|
||
|
|
business_results = await asyncio.gather(
|
||
|
|
*[task for task in business_tasks.values()],
|
||
|
|
return_exceptions=True
|
||
|
|
)
|
||
|
|
|
||
|
|
business = {}
|
||
|
|
for key, result in zip(business_tasks.keys(), business_results):
|
||
|
|
if isinstance(result, Exception):
|
||
|
|
business[key] = CheckResult(
|
||
|
|
name=key,
|
||
|
|
status=HealthStatus.UNKNOWN,
|
||
|
|
error=str(result)
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
business[key] = result
|
||
|
|
|
||
|
|
# 汇总错误
|
||
|
|
errors = []
|
||
|
|
all_checks = {**infrastructure, **business}
|
||
|
|
for check in all_checks.values():
|
||
|
|
if check.status in [HealthStatus.UNHEALTHY, HealthStatus.DEGRADED]:
|
||
|
|
errors.append({
|
||
|
|
"name": check.name,
|
||
|
|
"status": check.status.value,
|
||
|
|
"error": check.error,
|
||
|
|
"latency_ms": check.latency_ms
|
||
|
|
})
|
||
|
|
|
||
|
|
# 评估整体状态和影响级别
|
||
|
|
overall_status, overall_impact = self._evaluate_status(infrastructure, business)
|
||
|
|
|
||
|
|
return HealthCheckReport(
|
||
|
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||
|
|
overall_status=overall_status,
|
||
|
|
overall_impact=overall_impact,
|
||
|
|
infrastructure={k: v for k, v in infrastructure.items()},
|
||
|
|
business={k: v for k, v in business.items()},
|
||
|
|
errors=errors
|
||
|
|
)
|
||
|
|
|
||
|
|
def _evaluate_status(
|
||
|
|
self,
|
||
|
|
infrastructure: Dict[str, CheckResult],
|
||
|
|
business: Dict[str, CheckResult]
|
||
|
|
) -> tuple[HealthStatus, ImpactLevel]:
|
||
|
|
"""评估整体状态和影响级别"""
|
||
|
|
|
||
|
|
# 检查关键服务
|
||
|
|
critical_infra = ["backend", "redis", "postgres"]
|
||
|
|
critical_business = ["agents_api", "message_send"]
|
||
|
|
|
||
|
|
has_critical_failure = False
|
||
|
|
has_high_impact = False
|
||
|
|
|
||
|
|
# 基础设施检查
|
||
|
|
for key in critical_infra:
|
||
|
|
if key in infrastructure:
|
||
|
|
if infrastructure[key].status == HealthStatus.UNHEALTHY:
|
||
|
|
has_critical_failure = True
|
||
|
|
if key in ["backend", "redis", "postgres"]:
|
||
|
|
has_high_impact = True
|
||
|
|
|
||
|
|
# 业务检查
|
||
|
|
for key in critical_business:
|
||
|
|
if key in business:
|
||
|
|
if business[key].status == HealthStatus.UNHEALTHY:
|
||
|
|
has_critical_failure = True
|
||
|
|
has_high_impact = True
|
||
|
|
|
||
|
|
# 判断状态
|
||
|
|
if has_critical_failure:
|
||
|
|
return HealthStatus.UNHEALTHY, ImpactLevel.P0
|
||
|
|
elif has_high_impact:
|
||
|
|
return HealthStatus.DEGRADED, ImpactLevel.P1
|
||
|
|
else:
|
||
|
|
# 检查是否有降级
|
||
|
|
degraded = any(
|
||
|
|
c.status == HealthStatus.DEGRADED
|
||
|
|
for c in list(infrastructure.values()) + list(business.values())
|
||
|
|
)
|
||
|
|
if degraded:
|
||
|
|
return HealthStatus.DEGRADED, ImpactLevel.P2
|
||
|
|
|
||
|
|
return HealthStatus.HEALTHY, ImpactLevel.P3
|
||
|
|
|
||
|
|
def to_dict(self, report: HealthCheckReport) -> Dict[str, Any]:
|
||
|
|
"""转换为字典格式(JSON序列化)"""
|
||
|
|
|
||
|
|
def check_to_dict(c: CheckResult) -> Dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"status": c.status.value,
|
||
|
|
"latency_ms": c.latency_ms,
|
||
|
|
"error": c.error,
|
||
|
|
"details": c.details
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
"timestamp": report.timestamp,
|
||
|
|
"overall_status": report.overall_status.value,
|
||
|
|
"overall_impact": report.overall_impact.value,
|
||
|
|
"infrastructure": {k: check_to_dict(v) for k, v in report.infrastructure.items()},
|
||
|
|
"business": {k: check_to_dict(v) for k, v in report.business.items()},
|
||
|
|
"errors": report.errors
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# 全局单例
|
||
|
|
health_check_service = HealthCheckService()
|