feat: 企业微信机器人集成到后端服务,网页端可配置和重启

- BotManager: 后端启动时自动检测配置并拉起机器人(后台线程)
- WeComBotService: 新增 start_async() 异步模式,支持嵌入事件循环
- settings API: 增加 bot 配置字段(enabled/bot_id/secret)
- settings API: 增加 bot/status 和 bot/restart 端点
- 前端设置页: 增加企业微信机器人配置卡片(启用开关/ID/Secret)
- 前端设置页: 增加机器人状态徽章和重启按钮
- Secret 脱敏显示,仅展示前4位
This commit is contained in:
EduBrain Dev
2026-04-14 13:42:04 +08:00
parent 8ebf9a4587
commit e8a861fda2
4 changed files with 251 additions and 5 deletions

View File

@@ -12,6 +12,15 @@ from app.config import settings
router = APIRouter()
def _mask_secret(value: str | None, visible: int = 4) -> str | None:
"""对敏感字符串脱敏,保留前 visible 个字符"""
if not value:
return None
if len(value) <= visible:
return "****"
return value[:visible] + "****"
class SettingsResponse(BaseModel):
"""设置响应(隐藏敏感信息的中间位)"""
embedding_provider: str
@@ -37,6 +46,10 @@ class SettingsResponse(BaseModel):
has_dashscope_key: bool
has_aliyun_ocr: bool
has_tencent_ocr: bool
# 企业微信机器人
wework_bot_enabled: bool
wework_bot_id: str | None
wework_bot_secret_masked: str | None
@router.get("", response_model=SettingsResponse, summary="获取当前设置")
@@ -65,6 +78,9 @@ async def get_settings():
has_dashscope_key=bool(settings.DASHSCOPE_API_KEY),
has_aliyun_ocr=bool(settings.ALIYUN_OCR_ACCESS_KEY),
has_tencent_ocr=bool(settings.TENCENT_OCR_SECRET_ID),
wework_bot_enabled=settings.WEWORK_BOT_ENABLED,
wework_bot_id=settings.WEWORK_BOT_ID,
wework_bot_secret_masked=_mask_secret(settings.WEWORK_BOT_SECRET),
)
@@ -94,6 +110,10 @@ class SettingsUpdate(BaseModel):
chunk_overlap: int | None = None
search_limit: int | None = None
judge_batch_size: int | None = None
# 企业微信机器人
wework_bot_enabled: bool | None = None
wework_bot_id: str | None = None
wework_bot_secret: str | None = None
@router.put("", summary="更新设置")
@@ -139,6 +159,9 @@ async def update_settings(data: SettingsUpdate):
"chunk_overlap": "CHUNK_OVERLAP",
"search_limit": "SEARCH_LIMIT",
"judge_batch_size": "JUDGE_BATCH_SIZE",
"wework_bot_enabled": "WEWORK_BOT_ENABLED",
"wework_bot_id": "WEWORK_BOT_ID",
"wework_bot_secret": "WEWORK_BOT_SECRET",
}
for api_field, config_field in field_map.items():
@@ -221,3 +244,18 @@ async def test_ocr():
return {"success": True, "message": f"OCR 提供商 {provider.name} 已就绪"}
except Exception as e:
return {"success": False, "message": f"OCR 测试失败: {str(e)}"}
@router.post("/bot/restart", summary="重启企业微信机器人")
async def restart_bot():
"""重启企业微信机器人服务"""
from app.main import bot_manager
result = await bot_manager.restart()
return result
@router.get("/bot/status", summary="获取企业微信机器人状态")
async def get_bot_status():
"""获取企业微信机器人运行状态"""
from app.main import bot_manager
return bot_manager.get_status()

View File

@@ -5,8 +5,10 @@ FastAPI 应用入口
from __future__ import annotations
import asyncio
import json
import logging
import threading
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
@@ -18,6 +20,88 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from app.config import settings
from app.database import close_db, init_db
_logger = logging.getLogger(__name__)
class BotManager:
"""管理企业微信机器人的生命周期(在后台线程中运行)"""
def __init__(self):
self._thread: threading.Thread | None = None
self._running = False
def start(self):
"""在后台线程中启动机器人"""
if self._running:
_logger.info("企业微信机器人已在运行")
return
if not settings.WEWORK_BOT_ENABLED or not settings.WEWORK_BOT_ID or not settings.WEWORK_BOT_SECRET:
_logger.info("企业微信机器人未配置,跳过启动")
return
self._thread = threading.Thread(
target=self._run_bot, daemon=True, name="wework-bot"
)
self._thread.start()
_logger.info("企业微信机器人已在后台线程启动")
def _run_bot(self):
"""在线程中运行 bot需要新建事件循环"""
self._running = True
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._run_bot_async())
except Exception as e:
_logger.error("企业微信机器人线程异常退出: %s", e, exc_info=True)
finally:
loop.close()
self._running = False
async def _run_bot_async(self):
"""异步运行 bot"""
from app.wework_bot import WeComBotService
service = WeComBotService()
await service.start_async()
async def restart(self) -> dict:
"""重启机器人(先停后启)"""
was_running = self._running
# 停止旧的(设置 enabled=False 让旧线程自然退出)
old_id = settings.WEWORK_BOT_ID
old_secret = settings.WEWORK_BOT_SECRET
old_enabled = settings.WEWORK_BOT_ENABLED
if not old_enabled or not old_id or not old_secret:
return {"success": False, "message": "请先配置机器人 ID 和 Secret 并启用"}
# 等待旧线程结束
if self._thread and self._thread.is_alive():
# SDK 的 run() 会阻塞,无法从外部优雅停止
# 通过设置 enabled=False 让下次检查时退出
_logger.info("等待旧机器人线程结束...")
# 无法强制停止,提示用户
if was_running:
return {"success": False, "message": "旧机器人正在运行请稍后重试SDK 不支持强制停止)"}
# 启动新的
self.start()
return {"success": True, "message": "机器人已启动"}
def get_status(self) -> dict:
"""获取机器人状态"""
configured = bool(settings.WEWORK_BOT_ID and settings.WEWORK_BOT_SECRET)
return {
"enabled": settings.WEWORK_BOT_ENABLED,
"configured": configured,
"running": self._running,
"bot_id": settings.WEWORK_BOT_ID,
}
# 全局 bot 管理器
bot_manager = BotManager()
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
@@ -32,6 +116,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
settings.ensure_dirs()
await init_db()
# 启动企业微信机器人(后台线程)
bot_manager.start()
# 预热嵌入服务(懒加载,首次调用时初始化)
try:
from app.services.embedding_service import EmbeddingService

View File

@@ -18,6 +18,7 @@ import os
import re
import tempfile
import time
import asyncio
from datetime import datetime
import httpx
@@ -47,7 +48,7 @@ class WeComBotService:
self.ws_client: WSClient | None = None
def start(self):
"""启动机器人服务"""
"""启动机器人服务(阻塞模式,用于独立进程运行)"""
if not self.enabled:
logger.info("企业微信机器人未启用 (WEWORK_BOT_ENABLED=false)")
return
@@ -63,7 +64,35 @@ class WeComBotService:
)
)
# 注册事件
self._register_events()
logger.info("启动企业微信智能机器人...")
self.ws_client.run()
async def start_async(self):
"""启动机器人服务(异步模式,用于嵌入到其他事件循环)"""
if not self.enabled:
logger.info("企业微信机器人未启用 (WEWORK_BOT_ENABLED=false)")
return
if not self.bot_id or not self.bot_secret:
logger.error("WEWORK_BOT_ID 或 WEWORK_BOT_SECRET 未配置")
return
self.ws_client = WSClient(
WSClientOptions(
bot_id=self.bot_id,
secret=self.bot_secret,
)
)
self._register_events()
logger.info("启动企业微信智能机器人(异步模式)...")
await self.ws_client.connect()
# 永远等待,保持连接
await asyncio.Future()
def _register_events(self):
"""注册所有事件处理器"""
self.ws_client.on("authenticated", self._on_authenticated)
self.ws_client.on("message.text", self._on_text_message)
self.ws_client.on("message.image", self._on_image_message)
@@ -71,9 +100,6 @@ class WeComBotService:
self.ws_client.on("disconnected", self._on_disconnected)
self.ws_client.on("error", self._on_error)
logger.info("启动企业微信智能机器人...")
self.ws_client.run()
# ──────────────────────────── 事件处理 ────────────────────────────
def _on_authenticated(self):