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())
|
||||
|
||||
|
||||
# ---------- 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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
|
||||
@@ -108,7 +108,7 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
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:
|
||||
- it-desk-internal
|
||||
healthcheck:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
> 📝 **更新规则**:每次 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)
|
||||
|
||||
| # | 任务 | 重要程度 | 说明 |
|
||||
@@ -170,6 +195,7 @@
|
||||
| #101 | 管理后台日志命名区分(决策1) | 侧边栏「系统日志」明确区分「配置变更历史(A)」与「安全审计日志(B)」,避免管理员混淆;状态:待开发 |
|
||||
| #102 | 配置变更 A+B 双写边界确认(决策2) | 维持 `PUT /api/admin/configs/{key}` 同时写 `config_change_logs` 与 `audit_logs` 的 `config_change` 事件,不收敛;代码已实现,本文档锁定边界;状态:设计中 |
|
||||
| #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-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-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 列表/重置端点对齐 |
|
||||
|
||||
### 5.2 契约类(CTRT-)
|
||||
@@ -404,7 +404,7 @@ sequenceDiagram
|
||||
2. **管理端 MFA 用户列表端点**:`/admin/mfa/users` 是否随 PRD 作废?本设计**默认迁移**为 `GET /api/auth/otp-admin-users` 以保留管理页功能;若产品决定下线该管理页,则可一并删除。
|
||||
3. **`agents/login` 内联 pyotp 校验**:本次保持最小变更(不重构为复用 `MFAService`);如需消除重复实现,列为可选优化(不影响功能)。
|
||||
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 type { AxiosResponse } from 'axios'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 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 状态条目 */
|
||||
export interface MfaUserStatus {
|
||||
/** 员工 ID(企微 userid) */
|
||||
@@ -106,3 +137,32 @@ export async function resetMfa(employeeId: string): Promise<MfaAdminResetData> {
|
||||
)
|
||||
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
|
||||
|
||||
// 检查是否需要 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) {
|
||||
logging.value = false
|
||||
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') {
|
||||
|
||||
@@ -130,6 +130,15 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- OTP 首次绑定面板 -->
|
||||
<OtpBindPanel
|
||||
v-if="requireOtpBind && otpBindUser"
|
||||
:user-id="otpBindUser!.user_id"
|
||||
:name="otpBindUser!.name"
|
||||
@bind-success="onBindSuccess"
|
||||
@cancel="onBindCancel"
|
||||
/>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
@@ -160,6 +169,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
|
||||
import OtpBindPanel from '@/components/OtpBindPanel.vue'
|
||||
|
||||
// ==========================================================================
|
||||
// Store
|
||||
@@ -184,6 +194,12 @@ const loginForm = reactive({
|
||||
/** 是否需要 OTP 验证 */
|
||||
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>('')
|
||||
|
||||
@@ -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> {
|
||||
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) {
|
||||
// 检查是否需要 OTP 验证
|
||||
// @see docs/system_design.md AUTH-10 验收标准
|
||||
if (error instanceof Error && error.message === 'require_otp') {
|
||||
requireOtp.value = true
|
||||
loginForm.otpCode = ''
|
||||
@@ -461,6 +509,20 @@ async function handleLogin(): Promise<void> {
|
||||
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 : '登录失败,请重试'
|
||||
errorMsg.value = errMsg
|
||||
}
|
||||
|
||||
@@ -530,6 +530,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
messages.value.push({ ...resp.user_message, status: 'sent' })
|
||||
}
|
||||
|
||||
// 登记 message_id 防止轮询重复拉取
|
||||
trackProcessedMessageId(resp.user_message.message_id)
|
||||
|
||||
// 注意:AI 回复不再经 HTTP 同步返回(后端已改为 ai_reply: null),
|
||||
// 而是由后台任务经 WebSocket 推回(ai_reply_chunk / ai_reply 事件)。
|
||||
// 前端收到 WS ai_reply 终态后,在 handleAiReply 中追加真实 AI 消息。
|
||||
|
||||
Reference in New Issue
Block a user