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

This commit is contained in:
Simon
2026-07-09 17:42:23 +08:00
parent 3d152fc8eb
commit bfba7a07e9
13 changed files with 1208 additions and 26 deletions
+27
View File
@@ -89,6 +89,33 @@ async def get_dashboard_overview(
return success_response(data=overview.model_dump()) 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
View File
@@ -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:
+8 -4
View File
@@ -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()
+9 -8
View File
@@ -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}"
+1 -1
View File
@@ -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` |
--- ---
+2 -2
View File
@@ -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 后端同步移除,不再保留)
--- ---
+60
View File
@@ -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>
+14
View File
@@ -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') {
+62
View File
@@ -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
} }
+3
View File
@@ -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 消息。