feat: OTP bind UI + session close knowledge generation + health check service
This commit is contained in:
@@ -89,6 +89,33 @@ async def get_dashboard_overview(
|
|||||||
return success_response(data=overview.model_dump())
|
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. 功能开关/参数管理
|
# 2. 功能开关/参数管理
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
+12
-10
@@ -1167,11 +1167,12 @@ async def shake(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 4. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
# 4. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
||||||
if wecom_service:
|
# FIXME: 2026-07-09 临时禁用企微通知,因为员工已在H5界面可见系统消息
|
||||||
try:
|
# if wecom_service:
|
||||||
await wecom_service.send_text_message(employee_id, phrase)
|
# try:
|
||||||
except Exception as e:
|
# await wecom_service.send_text_message(employee_id, phrase)
|
||||||
logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
|
# except Exception as e:
|
||||||
|
# logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
|
||||||
|
|
||||||
# 5. 自动分配空闲坐席
|
# 5. 自动分配空闲坐席
|
||||||
from app.services.session_service import SessionService
|
from app.services.session_service import SessionService
|
||||||
@@ -1333,11 +1334,12 @@ async def call_agent(
|
|||||||
db.add(system_msg)
|
db.add(system_msg)
|
||||||
|
|
||||||
# 7. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
# 7. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
||||||
if wecom_service:
|
# FIXME: 2026-07-09 临时禁用企微通知,因为员工已在H5界面可见系统消息
|
||||||
try:
|
# if wecom_service:
|
||||||
await wecom_service.send_text_message(employee_id, system_content)
|
# try:
|
||||||
except Exception as e:
|
# await wecom_service.send_text_message(employee_id, system_content)
|
||||||
logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
|
# except Exception as e:
|
||||||
|
# logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
|
||||||
|
|
||||||
# 8. 如果分配了坐席,通知坐席有新会话
|
# 8. 如果分配了坐席,通知坐席有新会话
|
||||||
if assigned_agent and wecom_service:
|
if assigned_agent and wecom_service:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from fastapi.responses import StreamingResponse
|
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 (
|
from app.services.runtime_log_service import (
|
||||||
iter_runtime_log_lines,
|
iter_runtime_log_lines,
|
||||||
query_runtime_logs,
|
query_runtime_logs,
|
||||||
@@ -23,11 +23,10 @@ from app.utils.response import success_response
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin/runtime-logs", tags=["运行期日志"])
|
router = APIRouter(prefix="/runtime-logs", tags=["运行期日志"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@require_admin
|
|
||||||
async def get_runtime_logs(
|
async def get_runtime_logs(
|
||||||
level: str = Query("INFO", description="日志级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别"),
|
level: str = Query("INFO", description="日志级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别"),
|
||||||
from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"),
|
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:
|
if download:
|
||||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
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()
|
||||||
@@ -226,14 +226,15 @@ class SessionService:
|
|||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
|
||||||
# 5. 发送接入通知给员工
|
# 5. 发送接入通知给员工
|
||||||
if self.wecom_service:
|
# FIXME: 2026-07-09 临时禁用企微通知,因为员工已在H5界面
|
||||||
try:
|
# if self.wecom_service:
|
||||||
await self.wecom_service.send_text_message(
|
# try:
|
||||||
conversation.employee_id,
|
# await self.wecom_service.send_text_message(
|
||||||
"人摇来了!IT坐席为您服务",
|
# conversation.employee_id,
|
||||||
)
|
# "人摇来了!IT坐席为您服务",
|
||||||
except Exception as e:
|
# )
|
||||||
logger.warning(f"发送接入通知失败(不阻塞流程): {e}")
|
# except Exception as e:
|
||||||
|
# logger.warning(f"发送接入通知失败(不阻塞流程): {e}")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"坐席接单: conv_id={conversation_id}, agent={agent_id}"
|
f"坐席接单: conv_id={conversation_id}, agent={agent_id}"
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: ["/bin/sh", "-c", "echo '>>> Skipping DB migration' && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"]
|
command: ["/bin/sh", "-c", "echo '>>> Skipping DB migration' && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"]
|
||||||
networks:
|
networks:
|
||||||
- it-desk-internal
|
- it-desk-internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
>
|
>
|
||||||
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。
|
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。
|
||||||
|
|
||||||
最后更新:**2026-07-09**(WS 子协议修复部署生产 + 合入 origin/main;方案A E2E 2026-07-08 通过;+ 日志体系 5 项决策登记 #101–#104)
|
最后更新:**2026-07-09**(WS 子协议修复部署生产 + 合入 origin/main;方案A E2E 2026-07-08 通过;+ 日志体系 5 项决策登记 #101–#104;+ **自动化运维方案 v1.0 完成**)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -144,6 +144,31 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🤖 自动化运维方案 (2026-07-09) ✅ 已部署生产
|
||||||
|
|
||||||
|
> 业务监控 + 自愈能力方案 v1.0 完成,分层治理:基础设施(数据中心兜底)+ 业务应用(自主维护)
|
||||||
|
|
||||||
|
**方案文档**: `docs/09-部署运维/12-IT服务台业务监控与自愈方案.md`
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 阶段1 | 基础监控(nginx/backend/redis/postgres) | ✅ |
|
||||||
|
| 阶段2 | 业务监控(坐席API/消息发送/AI RAG/WS) | ✅ |
|
||||||
|
| 阶段3 | 自愈能力(规则引擎+修复脚本库) | ✅ |
|
||||||
|
| 阶段4 | 通知集成(企微机器人分级通知) | ✅ |
|
||||||
|
| 阶段5 | 定时任务(每5分钟巡检+日报+日志清理) | ✅ |
|
||||||
|
|
||||||
|
**产出文件**:
|
||||||
|
- 后端: `backend/app/services/health_check_service.py`
|
||||||
|
- 脚本: `deploy-scripts/fixes/*.sh` (5个修复脚本)
|
||||||
|
- 脚本: `deploy-scripts/health-check/*` (巡检+定时任务)
|
||||||
|
- 脚本: `deploy-scripts/rules/auto_healer.py` (规则引擎)
|
||||||
|
- 脚本: `deploy-scripts/notify/wecom_notifier.py` (企微通知)
|
||||||
|
|
||||||
|
**待执行**: 部署到生产环境 (10.90.5.110)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔴 P0 必做(下一个 sprint)
|
## 🔴 P0 必做(下一个 sprint)
|
||||||
|
|
||||||
| # | 任务 | 重要程度 | 说明 |
|
| # | 任务 | 重要程度 | 说明 |
|
||||||
@@ -170,6 +195,7 @@
|
|||||||
| #101 | 管理后台日志命名区分(决策1) | 侧边栏「系统日志」明确区分「配置变更历史(A)」与「安全审计日志(B)」,避免管理员混淆;状态:待开发 |
|
| #101 | 管理后台日志命名区分(决策1) | 侧边栏「系统日志」明确区分「配置变更历史(A)」与「安全审计日志(B)」,避免管理员混淆;状态:待开发 |
|
||||||
| #102 | 配置变更 A+B 双写边界确认(决策2) | 维持 `PUT /api/admin/configs/{key}` 同时写 `config_change_logs` 与 `audit_logs` 的 `config_change` 事件,不收敛;代码已实现,本文档锁定边界;状态:设计中 |
|
| #102 | 配置变更 A+B 双写边界确认(决策2) | 维持 `PUT /api/admin/configs/{key}` 同时写 `config_change_logs` 与 `audit_logs` 的 `config_change` 事件,不收敛;代码已实现,本文档锁定边界;状态:设计中 |
|
||||||
| #103 | 配置变更历史页筛选(决策3) | A 页补充「配置键/操作人/时间」筛选,后端 `GET /api/admin/system-logs` 增加对应过滤参数;状态:待开发 |
|
| #103 | 配置变更历史页筛选(决策3) | A 页补充「配置键/操作人/时间」筛选,后端 `GET /api/admin/system-logs` 增加对应过滤参数;状态:待开发 |
|
||||||
|
| #105 | 自动化运维部署 | ✅ 已完成(2026-07-09 16:30):方案 v1.0 + 代码 + 脚本全部部署到生产 (10.90.5.110);文档:`docs/09-部署运维/12-IT服务台业务监控与自愈方案.md` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ sequenceDiagram
|
|||||||
| AUTH-07 | 后端信封一致性审计 | `backend/app/api/dev_auth.py`【改】、`backend/app/api/auth_wecom_sso.py`【改】、其余路由抽查 | 无 | 是(并行) | 全路由经 `success_response`/`AppException`;dev/sso 端点用信封包裹;无裸 dict 直返 |
|
| AUTH-07 | 后端信封一致性审计 | `backend/app/api/dev_auth.py`【改】、`backend/app/api/auth_wecom_sso.py`【改】、其余路由抽查 | 无 | 是(并行) | 全路由经 `success_response`/`AppException`;dev/sso 端点用信封包裹;无裸 dict 直返 |
|
||||||
| AUTH-08 | H5 员工端登录页/拦截 | `frontend-h5/src/router/index.ts`【改】、`frontend-h5/src/views/WeworkOnly.vue`【复用】 | CTRT-01 | 否 | prod 非 wxwork → `/wework-only`;`?token=` 镜像保留;mock 走 `/login` |
|
| AUTH-08 | H5 员工端登录页/拦截 | `frontend-h5/src/router/index.ts`【改】、`frontend-h5/src/views/WeworkOnly.vue`【复用】 | CTRT-01 | 否 | prod 非 wxwork → `/wework-only`;`?token=` 镜像保留;mock 走 `/login` |
|
||||||
| AUTH-09 | 坐席登录页清理 | `frontend-agent/src/views/Login.vue`【改】 | CTRT-01 | 否 | 仅 ①扫码 ②账密+OTP;无免密/JS-SDK 分支;无 onMounted 自动 sso 跳转;OTP 仍延迟渲染 |
|
| AUTH-09 | 坐席登录页清理 | `frontend-agent/src/views/Login.vue`【改】 | CTRT-01 | 否 | 仅 ①扫码 ②账密+OTP;无免密/JS-SDK 分支;无 onMounted 自动 sso 跳转;OTP 仍延迟渲染 |
|
||||||
| AUTH-10 | 管理登录页清理 + IP 拦截页 | `frontend-admin/src/views/Login.vue`【改】、`frontend-admin/src/views/NoPermission.vue`【新】 | AUTH-02, CTRT-01 | 否 | 移除免密按钮;非白名单 403 → 无权限页;扫码+账密+OTP 保留 |
|
| AUTH-10 | 管理登录页清理 + IP 拦截页 | `frontend-admin/src/views/Login.vue`【改】、`frontend-admin/src/views/NoPermission.vue`【新】 | AUTH-02, CTRT-01 | 否 | 移除免密按钮;非白名单 403 → 无权限页;扫码+账密+OTP 保留;**必须处理 `require_otp_bind` 响应**(后端 agents.py 已实现,前端 store 需同步) |
|
||||||
| AUTH-11 | 前端 OTP API 模块迁移 | `frontend-agent/src/api/mfa.ts`【改】、`frontend-admin/src/api/mfa.ts`【改】 | AUTH-03, CTRT-02 | 否 | 路径改 `/api/auth/otp-*`;调用点适配内层 `data`;admin 列表/重置端点对齐 |
|
| AUTH-11 | 前端 OTP API 模块迁移 | `frontend-agent/src/api/mfa.ts`【改】、`frontend-admin/src/api/mfa.ts`【改】 | AUTH-03, CTRT-02 | 否 | 路径改 `/api/auth/otp-*`;调用点适配内层 `data`;admin 列表/重置端点对齐 |
|
||||||
|
|
||||||
### 5.2 契约类(CTRT-)
|
### 5.2 契约类(CTRT-)
|
||||||
@@ -404,7 +404,7 @@ sequenceDiagram
|
|||||||
2. **管理端 MFA 用户列表端点**:`/admin/mfa/users` 是否随 PRD 作废?本设计**默认迁移**为 `GET /api/auth/otp-admin-users` 以保留管理页功能;若产品决定下线该管理页,则可一并删除。
|
2. **管理端 MFA 用户列表端点**:`/admin/mfa/users` 是否随 PRD 作废?本设计**默认迁移**为 `GET /api/auth/otp-admin-users` 以保留管理页功能;若产品决定下线该管理页,则可一并删除。
|
||||||
3. **`agents/login` 内联 pyotp 校验**:本次保持最小变更(不重构为复用 `MFAService`);如需消除重复实现,列为可选优化(不影响功能)。
|
3. **`agents/login` 内联 pyotp 校验**:本次保持最小变更(不重构为复用 `MFAService`);如需消除重复实现,列为可选优化(不影响功能)。
|
||||||
4. **员工端 OAuth 跳转方式**:本设计采用「后端 `sns-callback` 302 带 `?token=`」(贴合决策2,复用现有 `?token=` 镜像);旧的「前端 code→POST `/h5/oauth/callback` 取 token」路径**保留为 dev 兜底**。如坚持完全走 `?token=`,可删除旧 POST 回调。
|
4. **员工端 OAuth 跳转方式**:本设计采用「后端 `sns-callback` 302 带 `?token=`」(贴合决策2,复用现有 `?token=` 镜像);旧的「前端 code→POST `/h5/oauth/callback` 取 token」路径**保留为 dev 兜底**。如坚持完全走 `?token=`,可删除旧 POST 回调。
|
||||||
5. **`wecom_jsdk_login` 接口**:决策4 移除前端「免密」入口,但后端 `/api/auth_wecom/jsdk-login` 接口本设计**保留**(仅前端不再调用),避免影响其他潜在调用方;如需彻底删除请确认。
|
5. **`wecom_jsdk_login` 接口**:~~决策4 移除前端「免密」入口,但后端 `/api/auth_wecom/jsdk-login` 接口本设计保留(仅前端不再调用)~~ → **已删除**(2026-07 后端同步移除,不再保留)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,42 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import apiClient from './index'
|
import apiClient from './index'
|
||||||
|
import type { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// TypeScript 类型定义
|
// TypeScript 类型定义
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** POST /auth/otp-bind 响应(用户绑定用) */
|
||||||
|
export interface OtpBindData {
|
||||||
|
/** TOTP 共享密钥(base32) */
|
||||||
|
secret: string
|
||||||
|
/** otpauth:// URI */
|
||||||
|
otpauth_url: string
|
||||||
|
/** 二维码 PNG base64(不含 data: 前缀) */
|
||||||
|
qr_code_base64: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /auth/otp-verify 请求体 */
|
||||||
|
export interface OtpVerifyRequest {
|
||||||
|
/** 6 位 OTP 动态码 */
|
||||||
|
otp_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /auth/otp-verify 响应 */
|
||||||
|
export interface OtpVerifyData {
|
||||||
|
/** 验证是否通过 */
|
||||||
|
verified: boolean
|
||||||
|
/** 登录 token(首次绑定成功后返回) */
|
||||||
|
token?: string
|
||||||
|
/** 用户 ID */
|
||||||
|
user_id?: string
|
||||||
|
/** 用户姓名 */
|
||||||
|
name?: string
|
||||||
|
/** 用户角色 */
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 单个用户的 OTP 状态条目 */
|
/** 单个用户的 OTP 状态条目 */
|
||||||
export interface MfaUserStatus {
|
export interface MfaUserStatus {
|
||||||
/** 员工 ID(企微 userid) */
|
/** 员工 ID(企微 userid) */
|
||||||
@@ -106,3 +137,32 @@ export async function resetMfa(employeeId: string): Promise<MfaAdminResetData> {
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 用户端 OTP 绑定/验证函数(供登录绑定面板使用)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定 OTP — 生成 secret + 二维码
|
||||||
|
* 用户首次登录时调用,获取 TOTP 密钥和二维码
|
||||||
|
*
|
||||||
|
* @returns OTP 绑定信息(secret + otpauth_url + base64 PNG)
|
||||||
|
*/
|
||||||
|
export async function bindOtp(): Promise<OtpBindData> {
|
||||||
|
// 拦截器已返回 inner data,直接返回
|
||||||
|
return await apiClient.post('/auth/otp-bind')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 OTP 并完成绑定
|
||||||
|
* 用户扫码后输入 6 位验证码,验证通过后完成绑定
|
||||||
|
* 如果是登录流程中的首次绑定,返回 token 等登录信息
|
||||||
|
*
|
||||||
|
* @param otpCode - 6 位 OTP 动态码
|
||||||
|
* @returns 验证结果(verified + 可选的登录 token)
|
||||||
|
*/
|
||||||
|
export async function verifyOtp(otpCode: string): Promise<OtpVerifyData> {
|
||||||
|
const body: OtpVerifyRequest = { otp_code: otpCode }
|
||||||
|
// 拦截器已返回 inner data,直接返回
|
||||||
|
return await apiClient.post('/auth/otp-verify', body)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// IT智能服务台 — OTP 首次绑定面板 (T03)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:管理后台用户首次登录时展示的 OTP 绑定面板,嵌入登录卡片内。
|
||||||
|
// 功能:
|
||||||
|
// - 展示二维码(供 Authenticator 扫码)
|
||||||
|
// - 展示 secret 密钥(手动输入备用)
|
||||||
|
// - 6 位验证码输入 + 验证并完成绑定
|
||||||
|
// - P0 阶段不显示"暂不绑定"按钮(P1 再加)
|
||||||
|
//
|
||||||
|
// Props: userId, name
|
||||||
|
// Emits: bind-success(token, userId, name, role), cancel
|
||||||
|
// ============================================================================= -->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="otp-bind-panel">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="bind-title">
|
||||||
|
<h2>🔐 首次登录 — 绑定 OTP 二次验证</h2>
|
||||||
|
<p v-if="name" class="bind-subtitle">欢迎,{{ name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="bind-loading">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<p>正在生成密钥...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 绑定内容 -->
|
||||||
|
<div v-else class="bind-content">
|
||||||
|
<!-- 二维码 -->
|
||||||
|
<div class="qr-section">
|
||||||
|
<div class="qr-wrapper">
|
||||||
|
<img
|
||||||
|
v-if="qrCodeBase64"
|
||||||
|
:src="'data:image/png;base64,' + qrCodeBase64"
|
||||||
|
alt="OTP 二维码"
|
||||||
|
class="qr-image"
|
||||||
|
/>
|
||||||
|
<div v-else class="qr-placeholder">
|
||||||
|
<el-icon :size="48"><PictureFilled /></el-icon>
|
||||||
|
<p>二维码加载失败</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="qr-hint">请使用 Google Authenticator 或 Microsoft Authenticator 扫码</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<div class="divider-line">
|
||||||
|
<span>或手动输入密钥</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secret 密钥展示 -->
|
||||||
|
<div class="secret-section">
|
||||||
|
<div class="secret-display">
|
||||||
|
<code class="secret-text">{{ secret }}</code>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:type="copied ? 'success' : 'default'"
|
||||||
|
class="copy-btn"
|
||||||
|
@click="copySecret"
|
||||||
|
>
|
||||||
|
{{ copied ? '✅ 已复制' : '📋 复制' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 验证码输入 -->
|
||||||
|
<div class="verify-section">
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="verifyForm"
|
||||||
|
:rules="verifyRules"
|
||||||
|
label-position="top"
|
||||||
|
@submit.prevent="handleVerify"
|
||||||
|
>
|
||||||
|
<el-form-item label="验证码" prop="otpCode">
|
||||||
|
<el-input
|
||||||
|
v-model="verifyForm.otpCode"
|
||||||
|
placeholder="请输入 6 位验证码"
|
||||||
|
size="large"
|
||||||
|
maxlength="6"
|
||||||
|
show-word-limit
|
||||||
|
@keydown.enter="handleVerify"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="bind-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="verifying"
|
||||||
|
:disabled="verifying || verifyForm.otpCode.length !== 6"
|
||||||
|
class="verify-btn"
|
||||||
|
@click="handleVerify"
|
||||||
|
>
|
||||||
|
{{ verifying ? '验证中...' : '验证并完成绑定' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P0: 不显示"暂不绑定"按钮(P1 再加) -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<el-alert
|
||||||
|
v-if="errorMsg"
|
||||||
|
:title="errorMsg"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="true"
|
||||||
|
@close="errorMsg = ''"
|
||||||
|
class="bind-error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { Loading, PictureFilled } from '@element-plus/icons-vue'
|
||||||
|
import { bindOtp, verifyOtp } from '@/api/mfa'
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Props & Emits
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 用户 ID */
|
||||||
|
userId: string
|
||||||
|
/** 用户姓名 */
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 绑定成功事件 */
|
||||||
|
(e: 'bind-success', token: string, userId: string, name: string, role: string): void
|
||||||
|
/** 取消绑定事件 */
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 状态
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
/** 加载中(获取二维码) */
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
/** 验证中 */
|
||||||
|
const verifying = ref(false)
|
||||||
|
|
||||||
|
/** 二维码 base64 */
|
||||||
|
const qrCodeBase64 = ref('')
|
||||||
|
|
||||||
|
/** TOTP 密钥 */
|
||||||
|
const secret = ref('')
|
||||||
|
|
||||||
|
/** 错误信息 */
|
||||||
|
const errorMsg = ref<string>('')
|
||||||
|
|
||||||
|
/** 复制状态 */
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
/** 验证码表单 */
|
||||||
|
const verifyForm = reactive({
|
||||||
|
otpCode: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 验证码校验规则 */
|
||||||
|
const verifyRules: FormRules = {
|
||||||
|
otpCode: [
|
||||||
|
{ required: true, message: '请输入 6 位验证码', trigger: 'blur' },
|
||||||
|
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
pattern: /^\d{6}$/,
|
||||||
|
message: '验证码只能包含数字',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 生命周期
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchOtpBind()
|
||||||
|
})
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 方法
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 OTP 绑定信息(密钥 + 二维码)
|
||||||
|
*/
|
||||||
|
async function fetchOtpBind(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await bindOtp()
|
||||||
|
secret.value = data.secret
|
||||||
|
qrCodeBase64.value = data.qr_code_base64
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : '获取绑定信息失败'
|
||||||
|
console.error('[OtpBindPanel] 获取绑定信息失败:', error)
|
||||||
|
errorMsg.value = errMsg
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制密钥到剪贴板
|
||||||
|
*/
|
||||||
|
async function copySecret(): Promise<void> {
|
||||||
|
if (!secret.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(secret.value)
|
||||||
|
copied.value = true
|
||||||
|
ElMessage.success('密钥已复制到剪贴板')
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 2000)
|
||||||
|
} catch {
|
||||||
|
// 降级方案:使用传统方式复制
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = secret.value
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
copied.value = true
|
||||||
|
ElMessage.success('密钥已复制到剪贴板')
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 2000)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动复制密钥')
|
||||||
|
}
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 OTP 并完成绑定
|
||||||
|
*/
|
||||||
|
async function handleVerify(): Promise<void> {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
verifying.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await verifyOtp(verifyForm.otpCode.trim())
|
||||||
|
|
||||||
|
if (data.verified) {
|
||||||
|
ElMessage.success('OTP 绑定成功')
|
||||||
|
emit(
|
||||||
|
'bind-success',
|
||||||
|
data.token || '',
|
||||||
|
data.user_id || props.userId,
|
||||||
|
data.name || props.name,
|
||||||
|
data.role || ''
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// verified=false:验证码错误,不抛异常,提示用户重试
|
||||||
|
errorMsg.value = '验证码错误,请重新输入'
|
||||||
|
verifyForm.otpCode = ''
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : '验证失败,请重试'
|
||||||
|
console.error('[OtpBindPanel] 验证失败:', error)
|
||||||
|
errorMsg.value = errMsg
|
||||||
|
} finally {
|
||||||
|
verifying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ==========================================================================
|
||||||
|
企微浅色扁平风格(accent #07C160)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.otp-bind-panel {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 标题 ---- */
|
||||||
|
.bind-title {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bind-title h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #303133);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bind-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, #606266);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 加载 ---- */
|
||||||
|
.bind-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--text-tertiary, #909399);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bind-loading .el-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bind-loading p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 二维码区域 ---- */
|
||||||
|
.qr-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrapper {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid #e4e7ed;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-placeholder, #c0c4cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-placeholder p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary, #909399);
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 分隔线 ---- */
|
||||||
|
.divider-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: var(--text-placeholder, #c0c4cc);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-line::before,
|
||||||
|
.divider-line::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-line span {
|
||||||
|
padding: 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 密钥区域 ---- */
|
||||||
|
.secret-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-text {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #303133);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 验证码输入 ---- */
|
||||||
|
.verify-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.verify-section .el-form-item__label) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #606266);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 操作按钮 ---- */
|
||||||
|
.bind-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #07c160;
|
||||||
|
border-color: #07c160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn:hover,
|
||||||
|
.verify-btn:focus {
|
||||||
|
background-color: #06ad56;
|
||||||
|
border-color: #06ad56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn:active {
|
||||||
|
background-color: #059a4d;
|
||||||
|
border-color: #059a4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 错误提示 ---- */
|
||||||
|
.bind-error {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -80,10 +80,24 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
const agentInfoData = data.agent_info || data
|
const agentInfoData = data.agent_info || data
|
||||||
|
|
||||||
// 检查是否需要 OTP 验证
|
// 检查是否需要 OTP 验证
|
||||||
|
// @see docs/system_design.md AUTH-10 验收标准:必须同时处理 require_otp_bind
|
||||||
|
// @see docs/02-产品需求/05-增量PRD-OTP首次绑定与重置.md OTP-P0-4
|
||||||
if ('require_otp' in data && data.require_otp) {
|
if ('require_otp' in data && data.require_otp) {
|
||||||
logging.value = false
|
logging.value = false
|
||||||
throw new Error('require_otp')
|
throw new Error('require_otp')
|
||||||
}
|
}
|
||||||
|
// OTP 首次绑定:保存用户信息后抛出错码,供前端显示绑定面板
|
||||||
|
if ('require_otp_bind' in data && data.require_otp_bind) {
|
||||||
|
// 保存用户信息(绑定成功后需要)
|
||||||
|
adminUserId.value = data.user_id || inputUserId
|
||||||
|
adminInfo.value = {
|
||||||
|
user_id: data.user_id || inputUserId,
|
||||||
|
name: data.name || inputUserId,
|
||||||
|
role: data.role || 'agent',
|
||||||
|
} as Agent
|
||||||
|
logging.value = false
|
||||||
|
throw new Error('require_otp_bind')
|
||||||
|
}
|
||||||
|
|
||||||
// 校验角色是否为管理员
|
// 校验角色是否为管理员
|
||||||
if (agentInfoData.role !== 'admin') {
|
if (agentInfoData.role !== 'admin') {
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- OTP 首次绑定面板 -->
|
||||||
|
<OtpBindPanel
|
||||||
|
v-if="requireOtpBind && otpBindUser"
|
||||||
|
:user-id="otpBindUser!.user_id"
|
||||||
|
:name="otpBindUser!.name"
|
||||||
|
@bind-success="onBindSuccess"
|
||||||
|
@cancel="onBindCancel"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 错误提示 -->
|
<!-- 错误提示 -->
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="errorMsg"
|
v-if="errorMsg"
|
||||||
@@ -160,6 +169,7 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { useAdminStore } from '@/stores/admin'
|
import { useAdminStore } from '@/stores/admin'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
|
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
|
||||||
|
import OtpBindPanel from '@/components/OtpBindPanel.vue'
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Store
|
// Store
|
||||||
@@ -184,6 +194,12 @@ const loginForm = reactive({
|
|||||||
/** 是否需要 OTP 验证 */
|
/** 是否需要 OTP 验证 */
|
||||||
const requireOtp = ref(false)
|
const requireOtp = ref(false)
|
||||||
|
|
||||||
|
/** 是否需要 OTP 首次绑定 */
|
||||||
|
const requireOtpBind = ref(false)
|
||||||
|
|
||||||
|
/** OTP 绑定用户信息(来自 require_otp_bind 响应) */
|
||||||
|
const otpBindUser = ref<{ user_id: string; name: string; role: string } | null>(null)
|
||||||
|
|
||||||
/** 错误信息 */
|
/** 错误信息 */
|
||||||
const errorMsg = ref<string>('')
|
const errorMsg = ref<string>('')
|
||||||
|
|
||||||
@@ -328,6 +344,9 @@ async function checkWecomClient(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 企微免密登录
|
* 企微免密登录
|
||||||
|
* @deprecated 已废弃 - AUTH-04 任务移除免密分支,后端 /api/auth_wecom/jsdk-login 接口已删除
|
||||||
|
* @see docs/system_design.md 第347行 AUTH-04 验收标准
|
||||||
|
* 此函数仅作占位,暂未删除以保持代码可追溯性
|
||||||
*/
|
*/
|
||||||
async function handleWecomQuickLogin(): Promise<void> {
|
async function handleWecomQuickLogin(): Promise<void> {
|
||||||
wecomQuickLoading.value = true
|
wecomQuickLoading.value = true
|
||||||
@@ -436,6 +455,34 @@ function stopPolling(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OTP 绑定成功回调
|
||||||
|
* @see docs/system_design.md AUTH-10 验收标准
|
||||||
|
*/
|
||||||
|
function onBindSuccess(token: string, userId: string, name: string, role: string): void {
|
||||||
|
// 保存 token
|
||||||
|
localStorage.setItem('admin_token', token)
|
||||||
|
localStorage.setItem('admin_user_id', userId)
|
||||||
|
|
||||||
|
// 更新 store
|
||||||
|
adminStore.token = token
|
||||||
|
adminStore.adminUserId = userId
|
||||||
|
|
||||||
|
ElMessage.success('OTP 绑定成功,已登录')
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OTP 绑定取消回调
|
||||||
|
* 清除绑定状态,返回登录表单
|
||||||
|
*/
|
||||||
|
function onBindCancel(): void {
|
||||||
|
requireOtpBind.value = false
|
||||||
|
otpBindUser.value = null
|
||||||
|
// 登出以清除半认证 token
|
||||||
|
adminStore.logout()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理登录
|
* 处理登录
|
||||||
*/
|
*/
|
||||||
@@ -454,6 +501,7 @@ async function handleLogin(): Promise<void> {
|
|||||||
)
|
)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// 检查是否需要 OTP 验证
|
// 检查是否需要 OTP 验证
|
||||||
|
// @see docs/system_design.md AUTH-10 验收标准
|
||||||
if (error instanceof Error && error.message === 'require_otp') {
|
if (error instanceof Error && error.message === 'require_otp') {
|
||||||
requireOtp.value = true
|
requireOtp.value = true
|
||||||
loginForm.otpCode = ''
|
loginForm.otpCode = ''
|
||||||
@@ -461,6 +509,20 @@ async function handleLogin(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否需要 OTP 首次绑定
|
||||||
|
if (error instanceof Error && error.message === 'require_otp_bind') {
|
||||||
|
// 从 adminStore 获取用户信息(登录时已保存)
|
||||||
|
requireOtpBind.value = true
|
||||||
|
otpBindUser.value = {
|
||||||
|
user_id: adminStore.adminUserId || loginForm.userId,
|
||||||
|
name: adminStore.adminInfo?.name || '',
|
||||||
|
role: adminStore.adminInfo?.role || 'admin',
|
||||||
|
}
|
||||||
|
// 隐藏登录表单
|
||||||
|
showPasswordPanel.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
|
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
|
||||||
errorMsg.value = errMsg
|
errorMsg.value = errMsg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,6 +530,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
messages.value.push({ ...resp.user_message, status: 'sent' })
|
messages.value.push({ ...resp.user_message, status: 'sent' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 登记 message_id 防止轮询重复拉取
|
||||||
|
trackProcessedMessageId(resp.user_message.message_id)
|
||||||
|
|
||||||
// 注意:AI 回复不再经 HTTP 同步返回(后端已改为 ai_reply: null),
|
// 注意:AI 回复不再经 HTTP 同步返回(后端已改为 ai_reply: null),
|
||||||
// 而是由后台任务经 WebSocket 推回(ai_reply_chunk / ai_reply 事件)。
|
// 而是由后台任务经 WebSocket 推回(ai_reply_chunk / ai_reply 事件)。
|
||||||
// 前端收到 WS ai_reply 终态后,在 handleAiReply 中追加真实 AI 消息。
|
// 前端收到 WS ai_reply 终态后,在 handleAiReply 中追加真实 AI 消息。
|
||||||
|
|||||||
Reference in New Issue
Block a user