feat: 新增飞书长连接模式,无需公网域名

## 🚀 重大更新

### 飞书集成升级
-  迁移到飞书官方 SDK 的事件订阅 2.0(长连接模式)
-  无需公网域名和 webhook 配置
-  支持内网部署
-  自动重连机制

### 核心功能优化
-  优化群聊隔离机制(每个用户在每个群独立会话)
-  增强日志输出(emoji 标记便于快速识别)
-  完善错误处理和异常恢复
-  添加 SSL 证书问题解决方案

### 新增文件
- `src/integrations/feishu_longconn_service.py` - 飞书长连接服务
- `start_feishu_bot.py` - 启动脚本
- `test_feishu_connection.py` - 连接诊断工具
- `docs/FEISHU_LONGCONN.md` - 详细使用文档
- `README.md` - 项目说明文档

### 技术改进
- 添加 lark-oapi==1.3.5 官方 SDK
- 升级 certifi 包以支持 SSL 验证
- 优化配置加载逻辑
- 改进会话管理机制

### 文档更新
- 新增飞书长连接模式完整文档
- 更新快速开始指南
- 添加常见问题解答(SSL、权限、部署等)
- 完善架构说明和技术栈介绍

## 📝 使用方式

启动飞书长连接服务(无需公网域名):
```bash
python3 start_feishu_bot.py
```

详见:docs/FEISHU_LONGCONN.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zhaojie
2026-02-11 14:10:18 +08:00
parent f5acb05e61
commit e3a0396567
18 changed files with 1501 additions and 112 deletions

View File

@@ -71,6 +71,18 @@ class AIAccuracyConfig:
human_resolution_confidence: float = 0.90
@dataclass
class RedisConfig:
"""Redis缓存配置"""
enabled: bool = True
host: str = "localhost"
port: int = 6379
db: int = 0
password: Optional[str] = None
pool_size: int = 10
default_ttl: int = 3600 # 默认缓存过期时间(秒)
# --- 统一配置管理器 ---
class UnifiedConfig:
@@ -86,6 +98,7 @@ class UnifiedConfig:
self.server = self._load_server_from_env()
self.feishu = self._load_feishu_from_env()
self.ai_accuracy = self._load_ai_accuracy_from_env()
self.redis = self._load_redis_from_env()
self.validate_config()
def _load_database_from_env(self) -> DatabaseConfig:
@@ -146,6 +159,19 @@ class UnifiedConfig:
logger.info("AI Accuracy config loaded.")
return config
def _load_redis_from_env(self) -> RedisConfig:
config = RedisConfig(
enabled=os.getenv("REDIS_ENABLED", "True").lower() in ('true', '1', 't'),
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", 6379)),
db=int(os.getenv("REDIS_DB", 0)),
password=os.getenv("REDIS_PASSWORD") or None,
pool_size=int(os.getenv("REDIS_POOL_SIZE", 10)),
default_ttl=int(os.getenv("REDIS_DEFAULT_TTL", 3600))
)
logger.info("Redis config loaded.")
return config
def validate_config(self):
"""在启动时验证关键配置"""
if not self.database.url:
@@ -166,6 +192,7 @@ class UnifiedConfig:
'server': asdict(self.server),
'feishu': asdict(self.feishu),
'ai_accuracy': asdict(self.ai_accuracy),
'redis': asdict(self.redis),
}
# --- 全局单例模式 ---

View File

@@ -49,6 +49,10 @@ class WorkOrder(Base):
dispatch_time = Column(DateTime, nullable=True) # 分发时间
region = Column(String(50), nullable=True) # 区域overseas/domestic- 用于区分海外/国内
# 系统优化字段
processing_efficiency = Column(Float) # 处理效率
resource_usage = Column(Text) # 资源使用情况
# 关联对话记录
conversations = relationship("Conversation", back_populates="work_order")
# 关联处理过程记录
@@ -69,6 +73,11 @@ class Conversation(Base):
ip_address = Column(String(45), nullable=True) # IP地址
invocation_method = Column(String(50), nullable=True) # 调用方式websocket, api等
# 系统优化字段
processing_time = Column(Float) # 处理时间
memory_usage = Column(Float) # 内存使用量
cpu_usage = Column(Float) # CPU使用率
work_order = relationship("WorkOrder", back_populates="conversations")
class KnowledgeEntry(Base):
@@ -89,6 +98,11 @@ class KnowledgeEntry(Base):
verified_at = Column(DateTime) # 验证时间
vector_embedding = Column(Text) # 向量嵌入的JSON字符串
# 系统优化字段
search_frequency = Column(Integer, default=0) # 搜索频率
last_accessed = Column(DateTime) # 最后访问时间
relevance_score = Column(Float) # 相关性评分
class VehicleData(Base):
"""车辆实时数据模型"""
__tablename__ = "vehicle_data"
@@ -120,6 +134,12 @@ class Analytics(Base):
category_distribution = Column(Text) # JSON格式的类别分布
created_at = Column(DateTime, default=datetime.now)
# 分析增强字段
performance_score = Column(Float) # 性能评分
quality_metrics = Column(Text) # 质量指标JSON格式
cost_analysis = Column(Text) # 成本分析JSON格式
optimization_suggestions = Column(Text) # 优化建议JSON格式
class Alert(Base):
"""预警模型"""
__tablename__ = "alerts"

View File

@@ -177,9 +177,34 @@ class ConfigManager:
return json.dumps(self.get_config_summary(), ensure_ascii=False, indent=2)
def import_config(self, config_json: str) -> bool:
"""导入配置(不再支持)"""
logger.warning("配置现在从 .env 文件读取,无法通过 API 导入")
return False
"""导入配置 - 从 JSON 更新 .env 文件"""
try:
import json
config_data = json.loads(config_json)
# 提取飞书配置
feishu_config = config_data.get('feishu', {})
# 准备更新参数
update_params = {}
if feishu_config.get('app_id'):
update_params['app_id'] = feishu_config['app_id']
if feishu_config.get('app_secret') and feishu_config['app_secret'] != '***':
update_params['app_secret'] = feishu_config['app_secret']
if feishu_config.get('app_token'):
update_params['app_token'] = feishu_config['app_token']
if feishu_config.get('table_id'):
update_params['table_id'] = feishu_config['table_id']
if update_params:
return self.update_feishu_config(**update_params)
logger.warning("导入的配置中没有有效的飞书配置")
return False
except Exception as e:
logger.error(f"导入配置失败: {e}")
return False
# 全局配置管理器实例
config_manager = ConfigManager()

View File

@@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
"""
飞书长连接服务(基于官方 SDK- 修正版
使用事件订阅 2.0 - 无需公网域名和 webhook 配置
"""
import logging
import json
from typing import Optional
import lark_oapi as lark
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1, ReplyMessageRequest, ReplyMessageRequestBody
from src.config.unified_config import get_config
from src.web.service_manager import service_manager
logger = logging.getLogger(__name__)
class FeishuLongConnService:
"""飞书长连接服务 - 使用官方 SDK"""
def __init__(self):
"""初始化飞书长连接服务"""
config = get_config()
self.app_id = config.feishu.app_id
self.app_secret = config.feishu.app_secret
logger.info("🚀 初始化飞书长连接服务...")
logger.info(f" - App ID: {self.app_id}")
logger.info(f" - App Secret: {self.app_secret[:10]}...")
# 创建飞书客户端
self.client = lark.Client.builder() \
.app_id(self.app_id) \
.app_secret(self.app_secret) \
.log_level(lark.LogLevel.DEBUG) \
.build()
logger.info("✅ 飞书客户端创建成功")
# 创建事件处理器
self.event_handler = lark.EventDispatcherHandler.builder(
"", "" # 长连接模式不需要 verification_token 和 encrypt_key
).register_p2_im_message_receive_v1(self._handle_message) \
.build()
logger.info("✅ 飞书事件处理器创建成功")
logger.info("✅ 飞书长连接服务初始化完成")
def _handle_message(self, data: P2ImMessageReceiveV1) -> None:
"""
处理接收到的消息事件
Args:
data: 飞书消息事件数据
"""
logger.info("=" * 80)
logger.info("📨 收到飞书消息事件!")
logger.info("=" * 80)
try:
# 提取消息信息
event = data.event
message = event.message
message_id = message.message_id
chat_id = message.chat_id
message_type = message.message_type
content = message.content
sender = event.sender
logger.info(f"📋 消息详情:")
logger.info(f" - 消息ID: {message_id}")
logger.info(f" - 群聊ID: {chat_id}")
logger.info(f" - 发送者ID: {sender.sender_id.user_id}")
logger.info(f" - 消息类型: {message_type}")
logger.info(f" - 原始内容: {content}")
# 只处理文本消息
if message_type != "text":
logger.info(f"⏭️ 跳过非文本消息类型: {message_type}")
return
# 解析消息内容
try:
content_json = json.loads(content)
text_content = content_json.get("text", "").strip()
except json.JSONDecodeError as e:
logger.error(f"❌ 解析消息内容失败: {e}")
return
logger.info(f"📝 文本内容: {text_content}")
# 移除 @机器人 的部分
mentions = message.mentions
if mentions:
logger.info(f"👥 检测到 {len(mentions)} 个提及")
for mention in mentions:
mention_name = mention.name
if mention_name:
logger.info(f" - @{mention_name}")
# 尝试多种 @ 格式
for prefix in [f"@{mention_name}", f"@{mention_name} "]:
if text_content.startswith(prefix):
text_content = text_content[len(prefix):].strip()
break
if not text_content:
logger.warning(f"⚠️ 移除@后内容为空,回复提示")
self._reply_message(message_id, "您好!请问有什么可以帮助您的吗?")
return
logger.info(f"✅ 清理后内容: {text_content}")
# 获取发送者ID
sender_id = sender.sender_id.user_id
# 构造会话用户ID群聊隔离
# 格式: feishu_群聊ID_用户ID
session_user_id = f"feishu_{chat_id}_{sender_id}"
logger.info(f"🔑 会话标识: {session_user_id}")
# 获取或创建会话
chat_manager = service_manager.get_chat_manager()
active_sessions = chat_manager.get_active_sessions()
session_id = None
for session in active_sessions:
if session.get('user_id') == session_user_id:
session_id = session.get('session_id')
logger.info(f"✅ 找到已有会话: {session_id}")
break
# 如果没有会话,创建新会话
if not session_id:
session_id = chat_manager.create_session(
user_id=session_user_id,
work_order_id=None
)
logger.info(f"🆕 创建新会话: {session_id}")
# 调用实时对话接口处理消息
logger.info(f"🤖 调用 TSP Assistant 处理消息...")
response_data = chat_manager.process_message(
session_id=session_id,
user_message=text_content,
ip_address=None,
invocation_method="feishu_longconn"
)
logger.info(f"📊 处理结果: {response_data.get('success')}")
# 提取回复
if response_data.get("success"):
reply_text = response_data.get("response") or response_data.get("content", "抱歉,我暂时无法回答这个问题。")
else:
error_msg = response_data.get('error', '未知错误')
reply_text = f"处理消息时出错: {error_msg}"
logger.error(f"❌ 处理失败: {error_msg}")
# 确保回复是字符串
if isinstance(reply_text, dict):
reply_text = reply_text.get('content', str(reply_text))
if not isinstance(reply_text, str):
reply_text = str(reply_text)
logger.info(f"📤 准备发送回复 (长度: {len(reply_text)})")
logger.info(f" 内容预览: {reply_text[:100]}...")
# 发送回复
self._reply_message(message_id, reply_text)
logger.info("=" * 80)
logger.info("✅ 消息处理完成")
logger.info("=" * 80)
except Exception as e:
logger.error(f"❌ 处理消息时发生错误: {e}", exc_info=True)
# 尝试发送错误提示
try:
if 'message_id' in locals():
self._reply_message(message_id, "抱歉,系统遇到了一些问题,请稍后重试。")
except:
pass
def _reply_message(self, message_id: str, content: str) -> bool:
"""
回复消息
Args:
message_id: 消息ID
content: 回复内容
Returns:
是否成功
"""
try:
logger.info(f"📧 发送回复到消息 {message_id}")
# 转义 JSON 特殊字符
content_escaped = content.replace('"', '\\"').replace('\n', '\\n')
# 构造回复请求
request = ReplyMessageRequest.builder() \
.message_id(message_id) \
.request_body(ReplyMessageRequestBody.builder()
.msg_type("text")
.content(f'{{"text":"{content_escaped}"}}')
.build()) \
.build()
# 发送回复
response = self.client.im.v1.message.reply(request)
if not response.success():
logger.error(f"❌ 回复失败: {response.code} - {response.msg}")
return False
logger.info(f"✅ 回复成功: {message_id}")
return True
except Exception as e:
logger.error(f"❌ 回复消息时发生错误: {e}", exc_info=True)
return False
def start(self):
"""
启动长连接客户端
这个方法会阻塞当前线程,持续监听飞书事件
"""
logger.info("=" * 80)
logger.info("🚀 启动飞书长连接客户端")
logger.info("=" * 80)
logger.info(f"📋 配置信息:")
logger.info(f" - App ID: {self.app_id}")
logger.info(f" - 模式: 事件订阅 2.0(长连接)")
logger.info(f" - 优势: 无需公网域名和 webhook 配置")
logger.info("=" * 80)
logger.info("💡 等待消息中... (按 Ctrl+C 停止)")
logger.info("=" * 80)
try:
# 创建长连接客户端
cli = lark.ws.Client(self.app_id, self.app_secret, event_handler=self.event_handler)
logger.info("🔌 正在建立与飞书服务器的连接...")
# 启动长连接(会阻塞)
cli.start()
except KeyboardInterrupt:
logger.info("")
logger.info("⏹️ 用户中断,停止飞书长连接客户端")
except Exception as e:
logger.error(f"❌ 飞书长连接客户端异常: {e}", exc_info=True)
raise
# 全局服务实例
_feishu_longconn_service = None
def get_feishu_longconn_service() -> FeishuLongConnService:
"""获取飞书长连接服务单例"""
global _feishu_longconn_service
if _feishu_longconn_service is None:
_feishu_longconn_service = FeishuLongConnService()
return _feishu_longconn_service

View File

@@ -356,12 +356,20 @@ class WorkOrderSyncService:
logger.warning(f"字段 '{key}' 包含复杂类型 {type(value).__name__},跳过")
continue
valid_data[key] = value
workorder = WorkOrder(**valid_data)
session.add(workorder)
session.commit()
session.refresh(workorder)
logger.info(f"创建工单成功: {workorder.order_id}")
# 在session关闭前访问所有需要的属性让SQLAlchemy加载它们
workorder_id = workorder.id
workorder_order_id = workorder.order_id
# 将对象从session中分离这样它就不再依赖session
session.expunge(workorder)
logger.info(f"创建工单成功: {workorder_order_id}")
return workorder
except Exception as e:
logger.error(f"创建工单失败: {e}")
@@ -371,6 +379,9 @@ class WorkOrderSyncService:
"""更新现有工单"""
try:
with db_manager.get_session() as session:
# 重新附加到session
session.add(workorder)
workorder.title = local_data.get("title", workorder.title)
workorder.description = local_data.get("description", workorder.description)
workorder.category = local_data.get("category", workorder.category)
@@ -381,10 +392,18 @@ class WorkOrderSyncService:
workorder.assignee = local_data.get("assignee", workorder.assignee)
workorder.solution = local_data.get("solution", workorder.solution)
workorder.ai_suggestion = local_data.get("ai_suggestion", workorder.ai_suggestion)
session.commit()
session.refresh(workorder)
logger.info(f"更新工单成功: {workorder.order_id}")
# 在session关闭前访问需要的属性
workorder_id = workorder.id
workorder_order_id = workorder.order_id
# 从session中分离
session.expunge(workorder)
logger.info(f"更新工单成功: {workorder_order_id}")
return workorder
except Exception as e:
logger.error(f"更新工单失败: {e}")
@@ -423,20 +442,39 @@ class WorkOrderSyncService:
logger.info(f"字段 '{field}' 的建议映射: {suggestions[0] if suggestions else ''}")
# 特殊字段处理
# 定义所有日期时间字段
datetime_fields = [
"created_at", "updated_at", "date_of_close",
"dispatch_time", "operating_time"
]
for local_field, value in local_data.items():
if local_field == "status" and value in self.status_mapping:
local_data[local_field] = self.status_mapping[value]
elif local_field == "priority" and value in self.priority_mapping:
local_data[local_field] = self.priority_mapping[value]
elif local_field in ["created_at", "updated_at", "date_of_close"] and value:
elif local_field in datetime_fields:
# 处理所有日期时间字段
try:
if isinstance(value, (int, float)):
local_data[local_field] = datetime.fromtimestamp(value / 1000)
else:
if value is None or value == "":
local_data[local_field] = None
elif isinstance(value, (int, float)):
# 如果值为0或负数设为None而不是转换
if value <= 0:
local_data[local_field] = None
else:
local_data[local_field] = datetime.fromtimestamp(value / 1000)
elif isinstance(value, str):
local_data[local_field] = datetime.fromisoformat(value.replace('Z', '+00:00'))
elif isinstance(value, datetime):
# 已经是datetime对象保持不变
pass
else:
# 其他类型设为None
local_data[local_field] = None
except Exception as e:
logger.warning(f"时间字段转换失败: {e}, 使用当前时间")
local_data[local_field] = datetime.now()
logger.warning(f"时间字段 {local_field} 转换失败: {e}, 设为None")
local_data[local_field] = None
# 生成标题使用TR Description作为标题
tr_description = feishu_fields.get("TR Description", "")

View File

@@ -11,7 +11,7 @@ import logging
from datetime import datetime, timedelta
from typing import Dict, Any
from flask import Flask, render_template, request, jsonify, send_from_directory, make_response
from flask import Flask, render_template, request, jsonify, send_from_directory, make_response, session, redirect, url_for
from flask_cors import CORS
from src.config.unified_config import get_config
@@ -43,23 +43,36 @@ from src.web.blueprints.feishu_bot import feishu_bot_bp
# 配置日志
logger = logging.getLogger(__name__)
# 抑制 /api/health 的访问日志
# 抑制高频监控接口的访问日志
werkzeug_logger = logging.getLogger('werkzeug')
class HealthLogFilter(logging.Filter):
class MonitoringLogFilter(logging.Filter):
"""过滤掉高频监控接口的访问日志"""
def filter(self, record):
try:
msg = record.getMessage()
return '/api/health' not in msg
# 过滤掉这些高频接口的日志
quiet_endpoints = [
'/api/health',
'/api/monitor/status',
'/api/analytics',
'/api/alerts'
]
return not any(endpoint in msg for endpoint in quiet_endpoints)
except Exception:
return True
werkzeug_logger.addFilter(HealthLogFilter())
werkzeug_logger.addFilter(MonitoringLogFilter())
# 创建Flask应用
app = Flask(__name__)
CORS(app)
# 配置 session secret key用于加密 session
app.config['SECRET_KEY'] = 'tsp-assistant-secret-key-change-in-production'
app.config['SESSION_TYPE'] = 'filesystem'
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # session 有效期 7 天
# 配置上传文件夹
UPLOAD_FOLDER = 'uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
@@ -67,6 +80,37 @@ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
# 使用统一的服务管理器
# 登录验证中间件
@app.before_request
def check_login():
"""检查用户是否已登录"""
# 白名单:无需登录即可访问的路径
whitelist = [
'/login',
'/api/auth/login',
'/api/auth/status',
'/api/feishu/bot/event', # ✅ 飞书机器人回调
'/static/',
'/uploads/'
]
# 调试日志(可以看到每个请求)
if request.path.startswith('/api/feishu/bot'):
logger.info(f"[DEBUG] 飞书机器人请求: {request.path}, 在白名单中: {any(request.path.startswith(path) for path in whitelist)}")
# 检查当前路径是否在白名单中
if any(request.path.startswith(path) for path in whitelist):
return None
# 检查用户是否已登录
if 'user_id' not in session:
# 如果是 API 请求,返回 401
if request.path.startswith('/api/'):
return jsonify({"error": "未登录", "code": 401}), 401
# 如果是页面请求,重定向到登录页
else:
return redirect(url_for('login_page'))
# 注册蓝图
app.register_blueprint(alerts_bp)
app.register_blueprint(workorders_bp)
@@ -85,6 +129,11 @@ app.register_blueprint(feishu_bot_bp)
# 页面路由
@app.route('/login')
def login_page():
"""登录页面"""
return render_template('login.html')
@app.route('/')
@app.route('/dashboard')
def index():

View File

@@ -6,7 +6,7 @@
import logging
import json
import threading
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, current_app
from src.integrations.feishu_service import FeishuService
from src.web.service_manager import service_manager
@@ -16,66 +16,117 @@ logger = logging.getLogger(__name__)
# 创建蓝图
feishu_bot_bp = Blueprint('feishu_bot', __name__, url_prefix='/api/feishu/bot')
# 在模块级别实例化飞书服务,以便复用
# 注意:这假设配置在启动时是固定的。如果配置可热更新,则需要调整。
feishu_service = FeishuService()
def _process_message_in_background(app, event_data: dict):
"""
在后台线程中处理消息,避免阻塞飞书的回调请求。
def _process_message_in_background(app_context, event_data: dict):
Args:
app: Flask应用实例
event_data: 飞书事件数据
"""
在后台线程中处理消息,避免阻塞飞书的回调请求。
"""
with app_context:
with app.app_context():
# 每个线程创建独立的飞书服务实例,避免token共享问题
feishu_service = FeishuService()
try:
# 1. 解析事件数据
message_id = event_data['event']['message']['message_id']
chat_id = event_data['event']['message']['chat_id']
# 内容是一个JSON字符串需要再次解析
content_json = json.loads(event_data['event']['message']['content'])
text_content = content_json.get('text', '').strip()
event = event_data.get('event', {})
message = event.get('message', {})
message_id = message.get('message_id')
chat_id = message.get('chat_id')
if not message_id or not chat_id:
logger.error(f"[Feishu Bot] 事件数据缺少必要字段: {event_data}")
return
# 内容是一个JSON字符串,需要再次解析
try:
content_json = json.loads(message.get('content', '{}'))
text_content = content_json.get('text', '').strip()
except json.JSONDecodeError as e:
logger.error(f"[Feishu Bot] 解析消息内容失败: {e}")
return
logger.info(f"[Feishu Bot] 后台开始处理消息ID: {message_id}, 内容: '{text_content}'")
# 2. 移除@机器人的部分
# 飞书的@消息格式通常是 "@机器人名 实际内容"
if event_data['event']['message'].get('mentions'):
for mention in event_data['event']['message']['mentions']:
# mention['key']是@内容,例如"@_user_1"
mentions = message.get('mentions', [])
if mentions:
for mention in mentions:
# mention['key']是@内容,例如"@_user_1"
# mention['name']是显示的名字
bot_mention_text = f"@{mention['name']}"
if text_content.startswith(bot_mention_text):
text_content = text_content[len(bot_mention_text):].strip()
break
mention_name = mention.get('name', '')
if mention_name:
# 尝试多种@格式
for prefix in [f"@{mention_name}", f"@{mention_name} "]:
if text_content.startswith(prefix):
text_content = text_content[len(prefix):].strip()
break
if not text_content:
logger.warning(f"[Feishu Bot] 移除@后内容为空不处理。消息ID: {message_id}")
logger.warning(f"[Feishu Bot] 移除@后内容为空,不处理。消息ID: {message_id}")
# 仍然回复一个提示
feishu_service.reply_to_message(message_id, "您好!请问有什么可以帮助您的吗?")
return
logger.info(f"[Feishu Bot] 清理后的消息内容: '{text_content}'")
# 3. 调用核心服务获取回复
assistant = service_manager.get_assistant()
# 注意process_message_agent 是一个异步方法,需要处理
# 在同步线程中运行异步方法
import asyncio
try:
loop = asyncio.get_running_loop()
except RuntimeError: # 'RuntimeError: There is no current event loop...'
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# 3. 获取或创建该飞书用户的会话(支持群聊隔离)
chat_manager = service_manager.get_chat_manager()
# 调用对话服务
logger.info(f"[Feishu Bot] 调用Agent服务处理消息...")
response_data = loop.run_until_complete(
assistant.process_message_agent(message=text_content, user_id=f"feishu_{chat_id}")
# 获取发送者ID从event中提取
sender_id = event.get('sender', {}).get('sender_id', {}).get('user_id', 'unknown')
# 群聊隔离:每个用户在每个群都有独立会话
# 格式feishu_群聊ID_用户ID
user_id = f"feishu_{chat_id}_{sender_id}"
logger.info(f"[Feishu Bot] 会话用户标识: {user_id}")
# 检查是否已有活跃会话
active_sessions = chat_manager.get_active_sessions()
session_id = None
for session in active_sessions:
if session.get('user_id') == user_id:
session_id = session.get('session_id')
logger.info(f"[Feishu Bot] 找到已有会话: {session_id}")
break
# 如果没有会话,创建新会话
if not session_id:
session_id = chat_manager.create_session(user_id=user_id, work_order_id=None)
logger.info(f"[Feishu Bot] 为用户 {sender_id} 在群聊 {chat_id} 创建新会话: {session_id}")
# 4. 调用实时对话接口处理消息
logger.info(f"[Feishu Bot] 调用实时对话接口处理消息...")
response_data = chat_manager.process_message(
session_id=session_id,
user_message=text_content,
ip_address=None,
invocation_method="feishu_bot"
)
logger.info(f"[Feishu Bot] Agent服务返回结果: {response_data}")
logger.info(f"[Feishu Bot] 实时对话接口返回结果: {response_data}")
# 4. 提取回复并发送
reply_text = response_data.get("message", "抱歉,我暂时无法回答这个问题。")
if isinstance(reply_text, dict): # 有时候返回的可能是字典
# 5. 提取回复并发送
if response_data.get("success"):
reply_text = response_data.get("response") or response_data.get("content", "抱歉,我暂时无法回答这个问题。")
else:
error_msg = response_data.get('error', '未知错误')
reply_text = f"抱歉,处理您的问题时遇到了一些问题。请稍后重试或联系客服。\n错误信息: {error_msg}"
logger.error(f"[Feishu Bot] 处理消息失败: {error_msg}")
# 确保回复是字符串
if isinstance(reply_text, dict):
reply_text = reply_text.get('content', str(reply_text))
logger.info(f"[Feishu Bot] 准备发送回复到飞书: '{reply_text}'")
if not isinstance(reply_text, str):
reply_text = str(reply_text)
logger.info(f"[Feishu Bot] 准备发送回复到飞书 (长度: {len(reply_text)})")
logger.debug(f"[Feishu Bot] 回复内容: {reply_text}")
success = feishu_service.reply_to_message(message_id, reply_text)
if success:
@@ -83,8 +134,16 @@ def _process_message_in_background(app_context, event_data: dict):
else:
logger.error(f"[Feishu Bot] 回复消息到飞书失败。消息ID: {message_id}")
except KeyError as e:
logger.error(f"[Feishu Bot] 事件数据格式错误,缺少字段: {e}", exc_info=True)
except Exception as e:
logger.error(f"[Feishu Bot] 后台处理消息时发生严重错误: {e}", exc_info=True)
# 尝试发送错误提示给用户
try:
if 'message_id' in locals():
feishu_service.reply_to_message(message_id, "抱歉,系统遇到了一些问题,请稍后重试。")
except:
pass
@feishu_bot_bp.route('/event', methods=['POST'])
@@ -94,30 +153,46 @@ def handle_feishu_event():
"""
# 1. 解析请求
data = request.json
logger.info(f"[Feishu Bot] 收到飞书事件回调:\n{json.dumps(data, indent=2)}")
if not data:
logger.warning("[Feishu Bot] 收到空的请求数据")
return jsonify({"status": "error", "message": "empty request"}), 400
logger.info(f"[Feishu Bot] 收到飞书事件回调:\n{json.dumps(data, indent=2, ensure_ascii=False)}")
# 2. 安全校验 (如果配置了)
# 此处可以添加Verification Token的校验逻辑
# headers = request.headers
# ...
# 可以在这里添加Verification Token的校验逻辑
# from src.config.unified_config import get_config
# config = get_config()
# if config.feishu.verification_token:
# token = request.headers.get('X-Lark-Request-Token')
# if token != config.feishu.verification_token:
# logger.warning("[Feishu Bot] Token验证失败")
# return jsonify({"status": "error", "message": "invalid token"}), 403
# 3. 处理URL验证挑战
if data and data.get("type") == "url_verification":
if data.get("type") == "url_verification":
challenge = data.get("challenge", "")
logger.info(f"[Feishu Bot] 收到URL验证请求返回challenge: {challenge}")
logger.info(f"[Feishu Bot] 收到URL验证请求,返回challenge: {challenge}")
return jsonify({"challenge": challenge})
# 4. 处理事件回调
if data and data.get("header", {}).get("event_type") == "im.message.receive_v1":
# 立即响应飞书,防止超时重试
event_type = data.get("header", {}).get("event_type")
if event_type == "im.message.receive_v1":
# 获取当前Flask应用实例
app = current_app._get_current_object()
# 立即在后台线程中处理,避免阻塞飞书回调
threading.Thread(
target=_process_message_in_background,
args=(request.environ['werkzeug.request'].environ['flask.app'].app_context(), data)
args=(app, data),
daemon=True # 设置为守护线程
).start()
logger.info("[Feishu Bot] 已将消息处理任务推送到后台线程并立即响应200 OK")
logger.info("[Feishu Bot] 已将消息处理任务推送到后台线程,并立即响应200 OK")
return jsonify({"status": "processing"})
# 5. 对于其他未知事件也返回成功避免飞书重试
logger.warning(f"[Feishu Bot] 收到未知类型的事件: {data.get('header', {}).get('event_type')}")
# 5. 对于其他未知事件,也返回成功,避免飞书重试
logger.warning(f"[Feishu Bot] 收到未知类型的事件: {event_type}")
return jsonify({"status": "ignored"})

View File

@@ -0,0 +1,287 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TSP 智能助手 - 登录</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.login-container {
background: white;
border-radius: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
overflow: hidden;
max-width: 400px;
width: 100%;
}
.login-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 30px;
text-align: center;
}
.login-header h1 {
font-size: 28px;
margin: 0;
font-weight: 600;
}
.login-header p {
margin: 10px 0 0;
opacity: 0.9;
font-size: 14px;
}
.login-body {
padding: 40px 30px;
}
.form-floating {
margin-bottom: 20px;
}
.form-floating label {
color: #6c757d;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25);
}
.btn-login {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
padding: 12px;
font-size: 16px;
font-weight: 600;
border-radius: 10px;
width: 100%;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
color: white;
}
.btn-login:active {
transform: translateY(0);
}
.remember-me {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.remember-me input {
margin-right: 8px;
}
.alert {
border-radius: 10px;
margin-bottom: 20px;
}
.logo-icon {
font-size: 48px;
margin-bottom: 10px;
}
.input-group-text {
background: transparent;
border-right: none;
}
.form-control {
border-left: none;
}
.input-icon-wrapper {
position: relative;
margin-bottom: 20px;
}
.input-icon-wrapper i {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
z-index: 10;
}
.input-icon-wrapper input {
padding-left: 45px;
border-radius: 10px;
}
</style>
</head>
<body>
<div class="login-container">
<!-- 登录头部 -->
<div class="login-header">
<div class="logo-icon">
<i class="bi bi-car-front-fill"></i>
</div>
<h1>TSP 智能助手</h1>
<p>Telematics Service Platform</p>
</div>
<!-- 登录表单 -->
<div class="login-body">
<!-- 错误提示 -->
<div id="alert-container"></div>
<form id="loginForm">
<!-- 用户名输入 -->
<div class="input-icon-wrapper">
<i class="bi bi-person-fill"></i>
<input type="text" class="form-control" id="username" name="username"
placeholder="用户名" required autofocus>
</div>
<!-- 密码输入 -->
<div class="input-icon-wrapper">
<i class="bi bi-lock-fill"></i>
<input type="password" class="form-control" id="password" name="password"
placeholder="密码" required>
</div>
<!-- 记住我 -->
<div class="remember-me">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">
记住我
</label>
</div>
<!-- 登录按钮 -->
<button type="submit" class="btn btn-login" id="loginBtn">
<span id="loginBtnText">登录</span>
<span id="loginBtnSpinner" class="spinner-border spinner-border-sm d-none" role="status">
<span class="visually-hidden">加载中...</span>
</span>
</button>
</form>
<!-- 提示信息 -->
<div class="text-center mt-4" style="color: #6c757d; font-size: 13px;">
<p class="mb-0">默认账号: <strong>admin</strong></p>
<p>默认密码: <strong>admin123</strong></p>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const loginForm = document.getElementById('loginForm');
const loginBtn = document.getElementById('loginBtn');
const loginBtnText = document.getElementById('loginBtnText');
const loginBtnSpinner = document.getElementById('loginBtnSpinner');
const alertContainer = document.getElementById('alert-container');
// 检查是否已登录
checkLoginStatus();
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
// 获取表单数据
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const remember = document.getElementById('remember').checked;
if (!username || !password) {
showAlert('请输入用户名和密码', 'danger');
return;
}
// 显示加载状态
setLoading(true);
clearAlert();
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password,
remember: remember
})
});
const data = await response.json();
if (response.ok && data.success) {
showAlert('登录成功,正在跳转...', 'success');
// 保存 token 到 localStorage
if (data.token) {
localStorage.setItem('auth_token', data.token);
}
// 延迟跳转
setTimeout(() => {
window.location.href = '/dashboard';
}, 500);
} else {
showAlert(data.message || '登录失败,请检查用户名和密码', 'danger');
setLoading(false);
}
} catch (error) {
console.error('登录错误:', error);
showAlert('网络错误,请稍后重试', 'danger');
setLoading(false);
}
});
function setLoading(loading) {
loginBtn.disabled = loading;
if (loading) {
loginBtnText.classList.add('d-none');
loginBtnSpinner.classList.remove('d-none');
} else {
loginBtnText.classList.remove('d-none');
loginBtnSpinner.classList.add('d-none');
}
}
function showAlert(message, type) {
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
<i class="bi bi-${type === 'success' ? 'check-circle-fill' : 'exclamation-triangle-fill'}"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
alertContainer.innerHTML = alertHtml;
}
function clearAlert() {
alertContainer.innerHTML = '';
}
async function checkLoginStatus() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
if (data.authenticated) {
// 已登录,直接跳转
window.location.href = '/dashboard';
}
} catch (error) {
console.log('未登录');
}
}
// 回车键快捷登录
document.getElementById('password').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
loginForm.dispatchEvent(new Event('submit'));
}
});
});
</script>
</body>
</html>