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:
@@ -4,7 +4,14 @@
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git config:*)"
|
||||
"Bash(git config:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(pip show:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(open:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
39
.env.example
39
.env.example
@@ -9,14 +9,14 @@
|
||||
SERVER_HOST=0.0.0.0
|
||||
|
||||
# The port for the main Flask web server.
|
||||
SERVER_PORT=5000
|
||||
SERVER_PORT=5001
|
||||
|
||||
# The port for the WebSocket server for real-time chat.
|
||||
WEBSOCKET_PORT=8765
|
||||
|
||||
# Set to "True" for development to enable debug mode and auto-reloading.
|
||||
# Set to "False" for production.
|
||||
DEBUG_MODE=True
|
||||
DEBUG_MODE=False
|
||||
|
||||
# Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
LOG_LEVEL=INFO
|
||||
@@ -28,7 +28,12 @@ LOG_LEVEL=INFO
|
||||
# The connection string for the primary database.
|
||||
# Format for MySQL: mysql+pymysql://<user>:<password>@<host>:<port>/<dbname>?charset=utf8mb4
|
||||
# Format for SQLite: sqlite:///./local_test.db
|
||||
DATABASE_URL=mysql+pymysql://tsp_assistant:123456@jeason.online/tsp_assistant?charset=utf8mb4
|
||||
|
||||
# 使用本地 SQLite(推荐用于开发和测试)
|
||||
DATABASE_URL=sqlite:///./data/tsp_assistant.db
|
||||
|
||||
# 远程 MySQL(生产环境使用,需要时取消注释)
|
||||
# DATABASE_URL=mysql+pymysql://tsp_assistant:123456@jeason.online/tsp_assistant?charset=utf8mb4
|
||||
|
||||
# ============================================================================
|
||||
# LARGE LANGUAGE MODEL (LLM) CONFIGURATION
|
||||
@@ -70,6 +75,9 @@ FEISHU_VERIFICATION_TOKEN=
|
||||
# The Encrypt Key for decrypting event data (if configured).
|
||||
FEISHU_ENCRYPT_KEY=
|
||||
|
||||
# The App Token of the Feishu multi-dimensional table document.
|
||||
FEISHU_APP_TOKEN=XXnEbiCmEaMblSs6FDJcFCqsnIg
|
||||
|
||||
# The ID of the Feishu multi-dimensional table for data synchronization.
|
||||
FEISHU_TABLE_ID=tblnl3vJPpgMTSiP
|
||||
|
||||
@@ -91,3 +99,28 @@ AI_SUGGESTION_CONFIDENCE=0.95
|
||||
|
||||
# The confidence score assigned when a human resolution is used.
|
||||
AI_HUMAN_RESOLUTION_CONFIDENCE=0.90
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REDIS CACHE CONFIGURATION
|
||||
# ============================================================================
|
||||
# Redis server host (use localhost for local development)
|
||||
REDIS_HOST=localhost
|
||||
|
||||
# Redis server port
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Redis database number (0-15)
|
||||
REDIS_DB=0
|
||||
|
||||
# Redis password (leave empty if no password)
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Redis connection pool size
|
||||
REDIS_POOL_SIZE=10
|
||||
|
||||
# Redis cache default TTL in seconds (3600 = 1 hour)
|
||||
REDIS_DEFAULT_TTL=3600
|
||||
|
||||
# Enable Redis cache (set to False to disable caching)
|
||||
REDIS_ENABLED=True
|
||||
|
||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 环境变量和敏感信息
|
||||
.env
|
||||
*.log
|
||||
|
||||
# 数据库
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# 日志文件
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
note.txt
|
||||
database_init_report.json
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# 测试文件
|
||||
test_*.py
|
||||
*_test.py
|
||||
|
||||
# 缓存
|
||||
.cache/
|
||||
395
docs/FEISHU_LONGCONN.md
Normal file
395
docs/FEISHU_LONGCONN.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# 飞书长连接模式使用指南
|
||||
|
||||
> ✅ **已验证可用** - 2026-02-11
|
||||
>
|
||||
> 本文档介绍如何使用飞书官方 SDK 的长连接模式,无需公网域名即可接入飞书机器人。
|
||||
|
||||
## 🎯 什么是长连接模式?
|
||||
|
||||
飞书官方 SDK 提供了**事件订阅 2.0(长连接模式)**,相比传统的 webhook 模式有以下优势:
|
||||
|
||||
| 特性 | Webhook 模式(旧) | 长连接模式(新)✅ |
|
||||
|------|-------------------|-------------------|
|
||||
| 公网域名 | ✅ 必需 | ❌ 不需要 |
|
||||
| SSL 证书 | ✅ 必需 | ❌ 不需要 |
|
||||
| 回调配置 | ✅ 需要配置 | ❌ 不需要 |
|
||||
| 内网部署 | ❌ 不支持 | ✅ 支持 |
|
||||
| 实时性 | 中等(HTTP 轮询) | ✅ 高(WebSocket) |
|
||||
| 稳定性 | 中等 | ✅ 高(自动重连) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 安装步骤
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd /Users/macos/Desktop/tsp-assist/assist
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**核心依赖:**
|
||||
- `lark-oapi==1.3.5` - 飞书官方 Python SDK
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# 飞书配置(必需)
|
||||
FEISHU_APP_ID=cli_xxxxxxxxxxxxx # 你的飞书应用 ID
|
||||
FEISHU_APP_SECRET=xxxxxxxxxxxxx # 你的飞书应用密钥
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:///./data/tsp_assistant.db
|
||||
|
||||
# LLM 配置
|
||||
LLM_PROVIDER=qwen
|
||||
LLM_API_KEY=sk-xxxxxxxxxxxxx
|
||||
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
LLM_MODEL=qwen-plus-latest
|
||||
```
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
python init_database.py
|
||||
```
|
||||
|
||||
### 4. 在飞书开放平台配置应用权限
|
||||
|
||||
访问 [飞书开放平台](https://open.feishu.cn/app),为您的应用添加以下权限:
|
||||
|
||||
**必需权限:**
|
||||
- ✅ `im:message` - 获取与发送单聊、群组消息
|
||||
- ✅ `im:message:send_as_bot` - 以应用的身份发消息
|
||||
- ✅ `im:chat` - 获取群组信息
|
||||
|
||||
**可选权限(用于工单同步):**
|
||||
- ✅ `bitable:app` - 查看、评论、编辑和管理多维表格
|
||||
|
||||
**注意:** 添加权限后需要重新发布应用版本。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 启动服务
|
||||
|
||||
### 方式 1: 使用启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
python start_feishu_bot.py
|
||||
```
|
||||
|
||||
启动后会看到:
|
||||
|
||||
```
|
||||
================================================================================
|
||||
🚀 TSP Assistant - 飞书长连接客户端
|
||||
================================================================================
|
||||
|
||||
📋 配置信息:
|
||||
- App ID: cli_xxxxxxxxxxxxx
|
||||
- 数据库: sqlite:///./data/tsp_assistant.db
|
||||
- LLM: qwen/qwen-plus-latest
|
||||
- 日志级别: INFO
|
||||
|
||||
🔌 启动模式: 事件订阅 2.0(长连接)
|
||||
✅ 优势:
|
||||
- 无需公网域名
|
||||
- 无需配置 webhook
|
||||
- 自动重连
|
||||
- 实时接收消息
|
||||
|
||||
💡 提示: 按 Ctrl+C 停止服务
|
||||
================================================================================
|
||||
|
||||
🚀 启动飞书长连接客户端...
|
||||
- App ID: cli_xxxxxxxxxxxxx
|
||||
- 模式: 事件订阅 2.0(长连接)
|
||||
- 无需公网域名和 webhook 配置
|
||||
✅ 飞书长连接服务初始化成功
|
||||
```
|
||||
|
||||
### 方式 2: 在代码中集成
|
||||
|
||||
如果您想将长连接服务集成到现有的 Flask 应用中:
|
||||
|
||||
```python
|
||||
import threading
|
||||
from src.integrations.feishu_longconn_service import get_feishu_longconn_service
|
||||
|
||||
# 在后台线程中启动长连接服务
|
||||
def start_feishu_in_background():
|
||||
service = get_feishu_longconn_service()
|
||||
service.start()
|
||||
|
||||
# 启动
|
||||
feishu_thread = threading.Thread(target=start_feishu_in_background, daemon=True)
|
||||
feishu_thread.start()
|
||||
|
||||
# 然后启动 Flask 应用
|
||||
app.run(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
### 1. 在飞书群聊中测试
|
||||
|
||||
**场景 1:单个用户对话**
|
||||
|
||||
```
|
||||
用户 A: @TSP助手 车辆无法连接网络怎么办?
|
||||
机器人: [基于工单知识库的智能回复]
|
||||
|
||||
用户 A: 还有其他解决方法吗?
|
||||
机器人: [基于上下文的多轮对话回复]
|
||||
```
|
||||
|
||||
**场景 2:多用户群聊隔离**
|
||||
|
||||
```
|
||||
群聊 1:
|
||||
用户 A: @TSP助手 我的车有问题
|
||||
机器人: [针对用户 A 的回复]
|
||||
|
||||
用户 B: @TSP助手 我的车有问题
|
||||
机器人: [针对用户 B 的独立回复,不受用户 A 影响]
|
||||
|
||||
群聊 2:
|
||||
用户 A: @TSP助手 我的车有问题
|
||||
机器人: [全新对话,不受群聊 1 影响]
|
||||
```
|
||||
|
||||
### 2. 查看日志
|
||||
|
||||
服务运行时会实时输出日志:
|
||||
|
||||
```
|
||||
📨 [Feishu LongConn] 收到消息
|
||||
- 消息ID: om_xxxxxxxxxxxxx
|
||||
- 群聊ID: oc_xxxxxxxxxxxxx
|
||||
- 发送者: ou_xxxxxxxxxxxxx
|
||||
- 消息类型: text
|
||||
- 原始内容: @TSP助手 车辆无法连接网络
|
||||
- 清理后内容: 车辆无法连接网络
|
||||
🔑 会话用户标识: feishu_oc_xxxxxxxxxxxxx_ou_xxxxxxxxxxxxx
|
||||
🆕 为用户 ou_xxxxxxxxxxxxx 在群聊 oc_xxxxxxxxxxxxx 创建新会话: session_xxxxxxxxxxxxx
|
||||
🤖 调用 TSP Assistant 处理消息...
|
||||
📤 准备发送回复 (长度: 156)
|
||||
✅ 成功回复消息: om_xxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 与旧模式的对比
|
||||
|
||||
### 旧模式(Webhook)- 已废弃
|
||||
|
||||
**文件:** `src/web/blueprints/feishu_bot.py`
|
||||
|
||||
**工作流程:**
|
||||
```
|
||||
飞书 → 公网域名 → Webhook → Flask → 处理消息
|
||||
```
|
||||
|
||||
**缺点:**
|
||||
- ❌ 需要公网域名
|
||||
- ❌ 需要配置 SSL
|
||||
- ❌ 内网无法部署
|
||||
- ❌ 需要在飞书后台配置回调地址
|
||||
|
||||
### 新模式(长连接)- 推荐 ✅
|
||||
|
||||
**文件:** `src/integrations/feishu_longconn_service.py`
|
||||
|
||||
**工作流程:**
|
||||
```
|
||||
飞书 ← WebSocket 长连接 ← SDK 客户端 ← 处理消息
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- ✅ 无需公网域名
|
||||
- ✅ 无需 SSL 证书
|
||||
- ✅ 内网可部署
|
||||
- ✅ 无需配置回调地址
|
||||
- ✅ 自动重连
|
||||
- ✅ 更稳定
|
||||
|
||||
---
|
||||
|
||||
## 📊 架构说明
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 飞书服务器 │
|
||||
│ (open.feishu.cn) │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│ WebSocket 长连接
|
||||
│ (事件订阅 2.0)
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TSP Assistant (内网部署) │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ feishu_longconn_service.py │ │
|
||||
│ │ - 飞书 SDK 长连接客户端 │ │
|
||||
│ │ - 自动接收消息事件 │ │
|
||||
│ │ - 群聊隔离 (feishu_群ID_用户ID) │ │
|
||||
│ └──────────────┬─────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ RealtimeChatManager │ │
|
||||
│ │ - 会话管理 │ │
|
||||
│ │ - 多轮对话 │ │
|
||||
│ └──────────────┬─────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ TSP Assistant 智能引擎 │ │
|
||||
│ │ - LLM 调用 │ │
|
||||
│ │ - 工单知识库匹配 │ │
|
||||
│ │ - AI 分析和建议 │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 高级配置
|
||||
|
||||
### 1. 调整日志级别
|
||||
|
||||
编辑 `.env`:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=DEBUG # 查看详细日志
|
||||
LOG_LEVEL=INFO # 默认(推荐)
|
||||
LOG_LEVEL=WARNING # 仅警告和错误
|
||||
```
|
||||
|
||||
### 2. 同时运行 Web 管理后台和长连接服务
|
||||
|
||||
**方式 1:使用两个终端**
|
||||
|
||||
```bash
|
||||
# 终端 1 - Web 管理后台
|
||||
python start_dashboard.py
|
||||
|
||||
# 终端 2 - 飞书长连接服务
|
||||
python start_feishu_bot.py
|
||||
```
|
||||
|
||||
**方式 2:使用进程管理器(推荐生产环境)**
|
||||
|
||||
创建 `supervisord.conf`:
|
||||
|
||||
```ini
|
||||
[program:tsp-web]
|
||||
command=python start_dashboard.py
|
||||
directory=/Users/macos/Desktop/tsp-assist/assist
|
||||
autostart=true
|
||||
autorestart=true
|
||||
|
||||
[program:tsp-feishu]
|
||||
command=python start_feishu_bot.py
|
||||
directory=/Users/macos/Desktop/tsp-assist/assist
|
||||
autostart=true
|
||||
autorestart=true
|
||||
```
|
||||
|
||||
启动:
|
||||
```bash
|
||||
supervisord -c supervisord.conf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q1: SSL 证书验证失败怎么办?
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain
|
||||
```
|
||||
|
||||
**解决方案(macOS):**
|
||||
|
||||
1. **运行 Python 证书安装脚本**(推荐):
|
||||
```bash
|
||||
open "/Applications/Python 3.10/Install Certificates.command"
|
||||
```
|
||||
|
||||
或者手动运行:
|
||||
```bash
|
||||
/Applications/Python\ 3.10/Install\ Certificates.command
|
||||
```
|
||||
|
||||
2. **升级 certifi 包**:
|
||||
```bash
|
||||
python3 -m pip install --upgrade certifi
|
||||
```
|
||||
|
||||
3. **验证修复**:
|
||||
```bash
|
||||
python3 -c "import urllib.request; urllib.request.urlopen('https://open.feishu.cn', timeout=5); print('✅ SSL 验证成功')"
|
||||
```
|
||||
|
||||
**解决方案(Linux/Windows):**
|
||||
```bash
|
||||
pip3 install --upgrade certifi
|
||||
```
|
||||
|
||||
### Q2: 启动后没有收到消息?
|
||||
|
||||
**检查清单:**
|
||||
1. ✅ 确认飞书应用权限已配置(`im:message`)
|
||||
2. ✅ 确认应用已发布最新版本
|
||||
3. ✅ 确认机器人已添加到群聊
|
||||
4. ✅ 查看日志是否有连接成功的提示
|
||||
5. ✅ 确认没有 SSL 证书错误(见 Q1)
|
||||
|
||||
### Q3: 提示"权限不足"?
|
||||
|
||||
**解决方案:**
|
||||
1. 登录飞书开放平台
|
||||
2. 进入应用管理 → 权限管理
|
||||
3. 添加所需权限
|
||||
4. 创建新版本并发布
|
||||
5. 重启长连接服务
|
||||
|
||||
### Q4: 如何停止服务?
|
||||
|
||||
按 `Ctrl+C` 即可优雅停止。
|
||||
|
||||
### Q5: 可以在多台服务器上运行吗?
|
||||
|
||||
**不建议**。同一个飞书应用只应运行一个长连接客户端实例,否则会导致消息重复处理。
|
||||
|
||||
### Q6: 如何迁移旧的 Webhook 模式?
|
||||
|
||||
**建议:**
|
||||
1. 保留旧代码(`feishu_bot.py`)作为备份
|
||||
2. 使用新的长连接模式(`start_feishu_bot.py`)
|
||||
3. 测试无误后,可以移除 webhook 端点
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
现在您的 TSP Assistant 已经支持**飞书官方 SDK 的长连接模式**!
|
||||
|
||||
✅ **核心优势:**
|
||||
- 无需公网域名
|
||||
- 无需 webhook 配置
|
||||
- 内网可部署
|
||||
- 自动重连
|
||||
- 群聊隔离
|
||||
- 完整的工单管理和 AI 分析功能
|
||||
|
||||
✅ **使用方式:**
|
||||
```bash
|
||||
python start_feishu_bot.py
|
||||
```
|
||||
|
||||
就这么简单!🚀
|
||||
113
init_database.py
113
init_database.py
@@ -38,6 +38,21 @@ class DatabaseInitializer:
|
||||
# 迁移历史记录
|
||||
self.migration_history = []
|
||||
|
||||
def _mask_db_url(self, url: str) -> str:
|
||||
"""隐藏数据库连接字符串中的敏感信息"""
|
||||
try:
|
||||
# 隐藏密码部分
|
||||
if '@' in url and ':' in url:
|
||||
parts = url.split('@')
|
||||
if len(parts) == 2:
|
||||
auth_part = parts[0]
|
||||
if ':' in auth_part:
|
||||
protocol_user = auth_part.rsplit(':', 1)[0]
|
||||
return f"{protocol_user}:***@{parts[1]}"
|
||||
return url
|
||||
except:
|
||||
return url
|
||||
|
||||
def _get_database_version(self) -> str:
|
||||
"""获取数据库版本信息"""
|
||||
try:
|
||||
@@ -61,14 +76,14 @@ class DatabaseInitializer:
|
||||
print("TSP智能助手数据库初始化系统")
|
||||
print("=" * 80)
|
||||
print(f"数据库类型: {self.db_version}")
|
||||
print(f"连接地址: {self.db_url}")
|
||||
print(f"连接地址: {self._mask_db_url(self.db_url)}")
|
||||
print(f"初始化时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print("=" * 80)
|
||||
|
||||
try:
|
||||
# 设置日志
|
||||
config = get_config()
|
||||
setup_logging(config.server.log_level, "logs/tsp_assistant.log")
|
||||
setup_logging(config.server.log_level, "logs/tsp_assistant.log")
|
||||
|
||||
# 测试数据库连接
|
||||
if not self._test_connection():
|
||||
@@ -95,6 +110,9 @@ class DatabaseInitializer:
|
||||
if not self._verify_database_integrity():
|
||||
return False
|
||||
|
||||
# 初始化 Redis 缓存(如果启用)
|
||||
self._initialize_redis_cache()
|
||||
|
||||
# 生成初始化报告
|
||||
self._generate_init_report()
|
||||
|
||||
@@ -521,7 +539,16 @@ class DatabaseInitializer:
|
||||
# 插入初始知识库数据
|
||||
initial_data = self._get_initial_knowledge_data()
|
||||
for data in initial_data:
|
||||
entry = KnowledgeEntry(**data)
|
||||
# 只使用模型定义中存在的字段
|
||||
entry = KnowledgeEntry(
|
||||
question=data['question'],
|
||||
answer=data['answer'],
|
||||
category=data['category'],
|
||||
confidence_score=data.get('confidence_score', 0.7),
|
||||
is_verified=data.get('is_verified', True),
|
||||
verified_by=data.get('verified_by', 'system'),
|
||||
verified_at=data.get('verified_at', datetime.now())
|
||||
)
|
||||
session.add(entry)
|
||||
|
||||
session.commit()
|
||||
@@ -548,9 +575,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.9,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.9
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "账户被锁定了怎么办?",
|
||||
@@ -559,9 +584,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.8,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.8
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "如何修改个人信息?",
|
||||
@@ -570,9 +593,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.7,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.7
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "支付失败怎么办?",
|
||||
@@ -581,9 +602,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.8,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.8
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "如何申请退款?",
|
||||
@@ -592,9 +611,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.7,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.7
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "系统无法访问怎么办?",
|
||||
@@ -603,9 +620,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.8,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.8
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "如何联系客服?",
|
||||
@@ -614,9 +629,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.9,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.9
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "如何远程启动车辆?",
|
||||
@@ -625,9 +638,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.9,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.9
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "APP显示车辆信息错误怎么办?",
|
||||
@@ -636,9 +647,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.8,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.8
|
||||
"verified_at": datetime.now()
|
||||
},
|
||||
{
|
||||
"question": "车辆无法远程启动的原因?",
|
||||
@@ -647,9 +656,7 @@ class DatabaseInitializer:
|
||||
"confidence_score": 0.9,
|
||||
"is_verified": True,
|
||||
"verified_by": "system",
|
||||
"verified_at": datetime.now(),
|
||||
"search_frequency": 0,
|
||||
"relevance_score": 0.9
|
||||
"verified_at": datetime.now()
|
||||
}
|
||||
]
|
||||
|
||||
@@ -688,10 +695,14 @@ class DatabaseInitializer:
|
||||
entry.is_verified = True
|
||||
entry.verified_by = "system_init"
|
||||
entry.verified_at = datetime.now()
|
||||
if not hasattr(entry, 'search_frequency'):
|
||||
entry.search_frequency = 0
|
||||
if not hasattr(entry, 'relevance_score'):
|
||||
entry.relevance_score = 0.7
|
||||
# 尝试设置优化字段(如果存在)
|
||||
try:
|
||||
if hasattr(entry, 'search_frequency') and entry.search_frequency is None:
|
||||
entry.search_frequency = 0
|
||||
if hasattr(entry, 'relevance_score') and entry.relevance_score is None:
|
||||
entry.relevance_score = 0.7
|
||||
except:
|
||||
pass # 如果字段不存在,忽略
|
||||
|
||||
session.commit()
|
||||
print(f" 成功验证 {len(unverified_entries)} 条知识库条目")
|
||||
@@ -795,6 +806,34 @@ class DatabaseInitializer:
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _initialize_redis_cache(self):
|
||||
"""初始化 Redis 缓存"""
|
||||
print("\n检查 Redis 缓存配置...")
|
||||
|
||||
try:
|
||||
from src.core.cache_manager import cache_manager
|
||||
|
||||
health = cache_manager.health_check()
|
||||
|
||||
if health["status"] == "healthy":
|
||||
print(" ✓ Redis 缓存连接成功")
|
||||
stats = cache_manager.get_stats()
|
||||
print(f" - 当前连接数: {stats.get('connected_clients', 0)}")
|
||||
print(f" - 内存使用: {stats.get('used_memory_human', '0B')}")
|
||||
print(f" - 总键数: {stats.get('total_keys', 0)}")
|
||||
elif health["status"] == "disabled":
|
||||
print(" ⚠ Redis 缓存未启用(可选功能)")
|
||||
else:
|
||||
print(f" ⚠ Redis 缓存连接失败: {health.get('message', '未知错误')}")
|
||||
print(" 提示: Redis 是可选功能,不影响系统正常运行")
|
||||
|
||||
except ImportError:
|
||||
print(" ⚠ Redis 库未安装,缓存功能将被禁用")
|
||||
print(" 提示: 运行 'pip install redis hiredis' 安装 Redis 支持")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Redis 初始化失败: {e}")
|
||||
print(" 提示: Redis 是可选功能,不影响系统正常运行")
|
||||
|
||||
def check_database_status(self) -> Dict[str, Any]:
|
||||
"""检查数据库状态"""
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
@@ -37,6 +37,8 @@ aiohttp==3.10.10
|
||||
# asyncio是Python内置模块,不需要安装
|
||||
|
||||
# Redis缓存
|
||||
redis==5.0.1
|
||||
hiredis==2.3.2
|
||||
|
||||
# 测试框架
|
||||
pytest==8.3.3
|
||||
@@ -63,4 +65,7 @@ httpx==0.27.2
|
||||
|
||||
# 数据验证
|
||||
pydantic==2.9.2
|
||||
marshmallow==3.23.3
|
||||
marshmallow==3.23.3
|
||||
|
||||
# 飞书官方 SDK(事件订阅 2.0 - 长连接模式)
|
||||
lark-oapi==1.3.5
|
||||
Binary file not shown.
@@ -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),
|
||||
}
|
||||
|
||||
# --- 全局单例模式 ---
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
269
src/integrations/feishu_longconn_service.py
Normal file
269
src/integrations/feishu_longconn_service.py
Normal 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
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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():
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"})
|
||||
|
||||
287
src/web/templates/login.html
Normal file
287
src/web/templates/login.html
Normal 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>
|
||||
76
start_feishu_bot.py
Executable file
76
start_feishu_bot.py
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
# 如果系统中 python 命令不存在,请使用: python3 start_feishu_bot.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
启动飞书长连接客户端
|
||||
使用官方 SDK 的事件订阅 2.0 模式,无需公网域名
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# 配置日志
|
||||
import os
|
||||
|
||||
# 从环境变量获取日志级别,默认为 INFO
|
||||
log_level = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
|
||||
# 配置日志格式
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level, logging.INFO),
|
||||
format='%(asctime)s [%(levelname)s] [%(name)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 加载配置
|
||||
from src.config.unified_config import get_config
|
||||
config = get_config()
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("🚀 TSP Assistant - 飞书长连接客户端")
|
||||
logger.info("=" * 80)
|
||||
logger.info("")
|
||||
logger.info("📋 配置信息:")
|
||||
logger.info(f" - App ID: {config.feishu.app_id}")
|
||||
logger.info(f" - 数据库: {config.database.url}")
|
||||
logger.info(f" - LLM: {config.llm.provider}/{config.llm.model}")
|
||||
logger.info(f" - 日志级别: {log_level}")
|
||||
logger.info("")
|
||||
logger.info("🔌 启动模式: 事件订阅 2.0(长连接)")
|
||||
logger.info("✅ 优势:")
|
||||
logger.info(" - 无需公网域名")
|
||||
logger.info(" - 无需配置 webhook")
|
||||
logger.info(" - 自动重连")
|
||||
logger.info(" - 实时接收消息")
|
||||
logger.info("")
|
||||
logger.info("💡 提示: 按 Ctrl+C 停止服务")
|
||||
logger.info("=" * 80)
|
||||
logger.info("")
|
||||
|
||||
# 导入并启动长连接服务
|
||||
from src.integrations.feishu_longconn_service import get_feishu_longconn_service
|
||||
|
||||
try:
|
||||
service = get_feishu_longconn_service()
|
||||
service.start() # 这里会阻塞,持续监听飞书事件
|
||||
except KeyboardInterrupt:
|
||||
logger.info("")
|
||||
logger.info("⏹️ 用户中断,正在停止服务...")
|
||||
logger.info("👋 再见!")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 服务异常退出: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user