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:
@@ -12,6 +12,15 @@ from app.config import settings
|
|||||||
router = APIRouter()
|
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):
|
class SettingsResponse(BaseModel):
|
||||||
"""设置响应(隐藏敏感信息的中间位)"""
|
"""设置响应(隐藏敏感信息的中间位)"""
|
||||||
embedding_provider: str
|
embedding_provider: str
|
||||||
@@ -37,6 +46,10 @@ class SettingsResponse(BaseModel):
|
|||||||
has_dashscope_key: bool
|
has_dashscope_key: bool
|
||||||
has_aliyun_ocr: bool
|
has_aliyun_ocr: bool
|
||||||
has_tencent_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="获取当前设置")
|
@router.get("", response_model=SettingsResponse, summary="获取当前设置")
|
||||||
@@ -65,6 +78,9 @@ async def get_settings():
|
|||||||
has_dashscope_key=bool(settings.DASHSCOPE_API_KEY),
|
has_dashscope_key=bool(settings.DASHSCOPE_API_KEY),
|
||||||
has_aliyun_ocr=bool(settings.ALIYUN_OCR_ACCESS_KEY),
|
has_aliyun_ocr=bool(settings.ALIYUN_OCR_ACCESS_KEY),
|
||||||
has_tencent_ocr=bool(settings.TENCENT_OCR_SECRET_ID),
|
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
|
chunk_overlap: int | None = None
|
||||||
search_limit: int | None = None
|
search_limit: int | None = None
|
||||||
judge_batch_size: 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="更新设置")
|
@router.put("", summary="更新设置")
|
||||||
@@ -139,6 +159,9 @@ async def update_settings(data: SettingsUpdate):
|
|||||||
"chunk_overlap": "CHUNK_OVERLAP",
|
"chunk_overlap": "CHUNK_OVERLAP",
|
||||||
"search_limit": "SEARCH_LIMIT",
|
"search_limit": "SEARCH_LIMIT",
|
||||||
"judge_batch_size": "JUDGE_BATCH_SIZE",
|
"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():
|
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} 已就绪"}
|
return {"success": True, "message": f"OCR 提供商 {provider.name} 已就绪"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "message": f"OCR 测试失败: {str(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()
|
||||||
|
|||||||
87
app/main.py
87
app/main.py
@@ -5,8 +5,10 @@ FastAPI 应用入口
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
@@ -18,6 +20,88 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import close_db, init_db
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
@@ -32,6 +116,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
settings.ensure_dirs()
|
settings.ensure_dirs()
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
|
# 启动企业微信机器人(后台线程)
|
||||||
|
bot_manager.start()
|
||||||
|
|
||||||
# 预热嵌入服务(懒加载,首次调用时初始化)
|
# 预热嵌入服务(懒加载,首次调用时初始化)
|
||||||
try:
|
try:
|
||||||
from app.services.embedding_service import EmbeddingService
|
from app.services.embedding_service import EmbeddingService
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -47,7 +48,7 @@ class WeComBotService:
|
|||||||
self.ws_client: WSClient | None = None
|
self.ws_client: WSClient | None = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""启动机器人服务"""
|
"""启动机器人服务(阻塞模式,用于独立进程运行)"""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
logger.info("企业微信机器人未启用 (WEWORK_BOT_ENABLED=false)")
|
logger.info("企业微信机器人未启用 (WEWORK_BOT_ENABLED=false)")
|
||||||
return
|
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("authenticated", self._on_authenticated)
|
||||||
self.ws_client.on("message.text", self._on_text_message)
|
self.ws_client.on("message.text", self._on_text_message)
|
||||||
self.ws_client.on("message.image", self._on_image_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("disconnected", self._on_disconnected)
|
||||||
self.ws_client.on("error", self._on_error)
|
self.ws_client.on("error", self._on_error)
|
||||||
|
|
||||||
logger.info("启动企业微信智能机器人...")
|
|
||||||
self.ws_client.run()
|
|
||||||
|
|
||||||
# ──────────────────────────── 事件处理 ────────────────────────────
|
# ──────────────────────────── 事件处理 ────────────────────────────
|
||||||
|
|
||||||
def _on_authenticated(self):
|
def _on_authenticated(self):
|
||||||
|
|||||||
@@ -1041,6 +1041,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 企业微信机器人 -->
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
|
||||||
|
<h3 class="section-title" style="margin-bottom:0;">企业微信机器人</h3>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<span id="bot-status-badge" class="badge badge-pending">检测中</span>
|
||||||
|
<button class="btn btn-secondary" onclick="restartBot()" id="bot-restart-btn" style="font-size:13px; padding:4px 12px;">重启机器人</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">启用机器人</label>
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; padding:6px 0;">
|
||||||
|
<input type="checkbox" id="setting-wework-bot-enabled" onchange="toggleBotEnabled()" style="width:16px; height:16px;">
|
||||||
|
<span style="font-size:13px; color:var(--text-secondary);">启用后服务启动时自动连接</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Bot ID</label>
|
||||||
|
<input type="text" id="setting-wework-bot-id" placeholder="企业微信机器人 ID" class="input-field">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Secret</label>
|
||||||
|
<input type="password" id="setting-wework-bot-secret" placeholder="企业微信机器人 Secret" class="input-field">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">服务地址</label>
|
||||||
|
<input type="text" id="setting-edubrain-base-url" value="http://localhost:8765" class="input-field" style="color:var(--text-secondary); font-size:13px;" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 图片搜索设置 -->
|
<!-- 图片搜索设置 -->
|
||||||
<div class="card" style="margin-bottom:16px;">
|
<div class="card" style="margin-bottom:16px;">
|
||||||
<h3 class="section-title">图片搜索设置</h3>
|
<h3 class="section-title">图片搜索设置</h3>
|
||||||
@@ -2612,12 +2644,22 @@
|
|||||||
if (data.judge_batch_size != null) {
|
if (data.judge_batch_size != null) {
|
||||||
document.getElementById('setting-judge-batch-size').value = data.judge_batch_size;
|
document.getElementById('setting-judge-batch-size').value = data.judge_batch_size;
|
||||||
}
|
}
|
||||||
|
// 企业微信机器人
|
||||||
|
document.getElementById('setting-wework-bot-enabled').checked = data.wework_bot_enabled;
|
||||||
|
document.getElementById('setting-wework-bot-id').value = data.wework_bot_id || '';
|
||||||
|
// secret 只显示脱敏值作为 placeholder
|
||||||
|
const secretInput = document.getElementById('setting-wework-bot-secret');
|
||||||
|
if (data.wework_bot_secret_masked) {
|
||||||
|
secretInput.placeholder = '当前: ' + data.wework_bot_secret_masked;
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 静默处理
|
// 静默处理
|
||||||
}
|
}
|
||||||
// 初始化字段显示状态
|
// 初始化字段显示状态
|
||||||
toggleEmbedFields();
|
toggleEmbedFields();
|
||||||
toggleOcrFields();
|
toggleOcrFields();
|
||||||
|
// 加载机器人状态
|
||||||
|
loadBotStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存设置 */
|
/** 保存设置 */
|
||||||
@@ -2631,6 +2673,12 @@
|
|||||||
if (!isNaN(judgeBatchSize) && judgeBatchSize >= 1 && judgeBatchSize <= 50) {
|
if (!isNaN(judgeBatchSize) && judgeBatchSize >= 1 && judgeBatchSize <= 50) {
|
||||||
body.judge_batch_size = judgeBatchSize;
|
body.judge_batch_size = judgeBatchSize;
|
||||||
}
|
}
|
||||||
|
// 企业微信机器人
|
||||||
|
body.wework_bot_enabled = document.getElementById('setting-wework-bot-enabled').checked;
|
||||||
|
const botId = document.getElementById('setting-wework-bot-id').value.trim();
|
||||||
|
if (botId) body.wework_bot_id = botId;
|
||||||
|
const botSecret = document.getElementById('setting-wework-bot-secret').value.trim();
|
||||||
|
if (botSecret) body.wework_bot_secret = botSecret;
|
||||||
if (Object.keys(body).length === 0) {
|
if (Object.keys(body).length === 0) {
|
||||||
showToast('没有需要保存的更改', 'info');
|
showToast('没有需要保存的更改', 'info');
|
||||||
return;
|
return;
|
||||||
@@ -2643,6 +2691,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 加载机器人状态 */
|
||||||
|
async function loadBotStatus() {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', '/settings/bot/status');
|
||||||
|
const badge = document.getElementById('bot-status-badge');
|
||||||
|
if (data.running) {
|
||||||
|
badge.className = 'badge badge-completed';
|
||||||
|
badge.textContent = '运行中';
|
||||||
|
} else if (data.configured) {
|
||||||
|
badge.className = 'badge badge-failed';
|
||||||
|
badge.textContent = '已停止';
|
||||||
|
} else {
|
||||||
|
badge.className = 'badge badge-pending';
|
||||||
|
badge.textContent = '未配置';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
document.getElementById('bot-status-badge').className = 'badge badge-pending';
|
||||||
|
document.getElementById('bot-status-badge').textContent = '未知';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重启企业微信机器人 */
|
||||||
|
async function restartBot() {
|
||||||
|
const btn = document.getElementById('bot-restart-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '重启中...';
|
||||||
|
try {
|
||||||
|
const result = await api('POST', '/settings/bot/restart');
|
||||||
|
if (result.success) {
|
||||||
|
showToast(result.message, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(result.message, 'error');
|
||||||
|
}
|
||||||
|
setTimeout(() => loadBotStatus(), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('重启失败: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '重启机器人';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换机器人启用状态 */
|
||||||
|
function toggleBotEnabled() {
|
||||||
|
// 仅切换 UI 状态,实际保存需要点"保存设置"
|
||||||
|
}
|
||||||
|
|
||||||
/** 测试嵌入模型连接 */
|
/** 测试嵌入模型连接 */
|
||||||
async function testEmbedding() {
|
async function testEmbedding() {
|
||||||
showToast('正在测试嵌入模型连接...', 'info');
|
showToast('正在测试嵌入模型连接...', 'info');
|
||||||
|
|||||||
Reference in New Issue
Block a user