feat: OTP bind UI + session close knowledge generation + health check service

This commit is contained in:
Simon
2026-07-09 17:42:23 +08:00
parent 3d152fc8eb
commit bfba7a07e9
13 changed files with 1208 additions and 26 deletions
+27
View File
@@ -89,6 +89,33 @@ async def get_dashboard_overview(
return success_response(data=overview.model_dump())
# ---------- GET /api/admin/health-check ----------
@router.get("/health-check")
async def get_health_check(
admin: Agent = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取系统健康检查报告。
包含:
- 基础设施:nginx/backend/redis/postgres
- 业务能力:坐席API/消息发送/企微回调/AI-RAG/WebSocket
返回整体状态、影响级别(P0-P3)、各项检查详情、错误列表。
Args:
admin: 管理员
db: 数据库会话
Returns:
Dict: 统一响应格式,包含健康检查报告
"""
from app.services.health_check_service import health_check_service
report = await health_check_service.check_all(db)
return success_response(data=health_check_service.to_dict(report))
# ==========================================================================
# 2. 功能开关/参数管理
# ==========================================================================
+12 -10
View File
@@ -1167,11 +1167,12 @@ async def shake(
await db.flush()
# 4. 通过企微 API 发送话术给员工(使用共享 WecomService
if wecom_service:
try:
await wecom_service.send_text_message(employee_id, phrase)
except Exception as e:
logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
# FIXME: 2026-07-09 临时禁用企微通知,因为员工已在H5界面可见系统消息
# if wecom_service:
# try:
# await wecom_service.send_text_message(employee_id, phrase)
# except Exception as e:
# logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
# 5. 自动分配空闲坐席
from app.services.session_service import SessionService
@@ -1333,11 +1334,12 @@ async def call_agent(
db.add(system_msg)
# 7. 通过企微 API 发送话术给员工(使用共享 WecomService
if wecom_service:
try:
await wecom_service.send_text_message(employee_id, system_content)
except Exception as e:
logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
# FIXME: 2026-07-09 临时禁用企微通知,因为员工已在H5界面可见系统消息
# if wecom_service:
# try:
# await wecom_service.send_text_message(employee_id, system_content)
# except Exception as e:
# logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
# 8. 如果分配了坐席,通知坐席有新会话
if assigned_agent and wecom_service:
+8 -4
View File
@@ -14,7 +14,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from app.dependencies import require_admin, get_current_user, UserInfo
from app.dependencies import get_current_user, UserInfo
from app.services.runtime_log_service import (
iter_runtime_log_lines,
query_runtime_logs,
@@ -23,11 +23,10 @@ from app.utils.response import success_response
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/runtime-logs", tags=["运行期日志"])
router = APIRouter(prefix="/runtime-logs", tags=["运行期日志"])
@router.get("")
@require_admin
async def get_runtime_logs(
level: str = Query("INFO", description="日志级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别"),
from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"),
@@ -40,8 +39,13 @@ async def get_runtime_logs(
):
"""查询后端运行期日志(分页 + 级别/时间/关键字筛选)。
权限:仅 admin 角色可访问,非 admin 由 require_admin 装饰器返回 403。
权限:仅 admin 角色可访问,非 admin 返回 403。
"""
# 权限检查:仅 admin 可访问
if current_user.role != "admin":
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="需要管理员权限")
# 下载模式:流式返回命中行文本,触发浏览器文件下载
if download:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
@@ -0,0 +1,505 @@
# =============================================================================
# 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()
+9 -8
View File
@@ -226,14 +226,15 @@ class SessionService:
await self.db.flush()
# 5. 发送接入通知给员工
if self.wecom_service:
try:
await self.wecom_service.send_text_message(
conversation.employee_id,
"人摇来了!IT坐席为您服务",
)
except Exception as e:
logger.warning(f"发送接入通知失败(不阻塞流程): {e}")
# FIXME: 2026-07-09 临时禁用企微通知,因为员工已在H5界面
# if self.wecom_service:
# try:
# await self.wecom_service.send_text_message(
# conversation.employee_id,
# "人摇来了!IT坐席为您服务",
# )
# except Exception as e:
# logger.warning(f"发送接入通知失败(不阻塞流程): {e}")
logger.info(
f"坐席接单: conv_id={conversation_id}, agent={agent_id}"