v2.0: 架构大版本升级

任务 3.2+3.3: 飞书入口迁移到 MessagePipeline
- feishu_bot.py 改用 pipeline.handle_message(去掉 30 行会话管理代码)
- feishu_longconn_service.py 改用 pipeline.handle_message(去掉 25 行)
- 各入口只负责协议适配,业务逻辑统一在 Pipeline

任务 5: 统一配置管理
- 新增 src/config/config_service.py(ConfigService 单例)
- 优先级:环境变量 > system_settings.json > 代码默认值
- 支持点号分隔的嵌套 key、自动类型转换

任务 8: 密码哈希升级
- SHA-256  bcrypt(User.set_password/check_password)
- AuthManager.hash_password/verify_password 同步升级
- 兼容旧密码:登录时检测 SHA-256 格式,验证通过后自动升级为 bcrypt
- auth_manager.secret_key 改为从环境变量读取

任务 9: 前端事件总线
- TSPDashboard 新增 on/off/emit 方法
- 模块间可通过事件通信,不再只靠直接读写共享状态

README.md 重写
- 功能概览、技术栈、快速开始、项目结构
- 架构要点、多租户、飞书机器人、环境变量
- 开发和部署说明
This commit is contained in:
2026-04-08 08:53:43 +08:00
parent db992be02a
commit 45badfee82
8 changed files with 270 additions and 598 deletions

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""
统一配置服务
优先级:环境变量 > system_settings.json > 代码默认值
"""
import os
import json
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
_SETTINGS_PATH = os.path.join('data', 'system_settings.json')
class ConfigService:
"""统一配置读写接口"""
def __init__(self):
self._file_cache = None
self._file_mtime = 0
def _load_file(self) -> dict:
"""加载 system_settings.json带文件修改时间缓存"""
try:
if os.path.exists(_SETTINGS_PATH):
mtime = os.path.getmtime(_SETTINGS_PATH)
if mtime != self._file_mtime or self._file_cache is None:
with open(_SETTINGS_PATH, 'r', encoding='utf-8') as f:
self._file_cache = json.load(f)
self._file_mtime = mtime
return self._file_cache or {}
except Exception as e:
logger.debug(f"加载配置文件失败: {e}")
return {}
def get(self, key: str, default: Any = None) -> Any:
"""
获取配置值。
优先级:环境变量 > system_settings.json > default
"""
# 1. 环境变量key 转大写,点号转下划线)
env_key = key.upper().replace('.', '_')
env_val = os.environ.get(env_key)
if env_val is not None:
return self._cast(env_val, default)
# 2. system_settings.json支持点号分隔的嵌套 key
settings = self._load_file()
parts = key.split('.')
val = settings
for part in parts:
if isinstance(val, dict):
val = val.get(part)
else:
val = None
break
if val is not None:
return val
return default
def get_section(self, section: str) -> dict:
"""获取配置文件中的一个 section"""
settings = self._load_file()
return settings.get(section, {})
def set(self, key: str, value: Any):
"""写入配置到 system_settings.json"""
settings = self._load_file()
parts = key.split('.')
target = settings
for part in parts[:-1]:
target = target.setdefault(part, {})
target[parts[-1]] = value
self._save_file(settings)
def _save_file(self, settings: dict):
os.makedirs('data', exist_ok=True)
with open(_SETTINGS_PATH, 'w', encoding='utf-8') as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
self._file_cache = settings
self._file_mtime = os.path.getmtime(_SETTINGS_PATH)
@staticmethod
def _cast(value: str, default: Any) -> Any:
"""根据 default 的类型自动转换环境变量字符串"""
if default is None:
return value
if isinstance(default, bool):
return value.lower() in ('true', '1', 'yes')
if isinstance(default, int):
try: return int(value)
except: return default
if isinstance(default, float):
try: return float(value)
except: return default
return value
# 全局单例
config_service = ConfigService()

View File

@@ -18,16 +18,21 @@ class AuthManager:
"""认证管理器"""
def __init__(self):
self.secret_key = "your-secret-key-change-this-in-production" # 应该从配置中读取
import os
self.secret_key = os.environ.get('SECRET_KEY', 'change-this-in-production')
self.token_expiry = timedelta(hours=24)
def hash_password(self, password: str) -> str:
"""密码哈希"""
return hashlib.sha256(password.encode()).hexdigest()
"""密码哈希bcrypt"""
import bcrypt
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(self, password: str, password_hash: str) -> bool:
"""验证密码"""
return self.hash_password(password) == password_hash
"""验证密码(兼容旧 SHA-256"""
import bcrypt
if password_hash and password_hash.startswith('$2b$'):
return bcrypt.checkpw(password.encode(), password_hash.encode())
return hashlib.sha256(password.encode()).hexdigest() == password_hash
def generate_token(self, user_data: dict) -> str:
"""生成JWT token"""

View File

@@ -294,12 +294,21 @@ class User(Base):
last_login = Column(DateTime)
def set_password(self, password):
"""设置密码哈希"""
self.password_hash = hashlib.sha256(password.encode()).hexdigest()
"""设置密码哈希bcrypt"""
import bcrypt
self.password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def check_password(self, password):
"""验证密码"""
return self.password_hash == hashlib.sha256(password.encode()).hexdigest()
"""验证密码(兼容旧 SHA-256 格式,验证通过后自动升级为 bcrypt"""
import bcrypt
if self.password_hash and self.password_hash.startswith('$2b$'):
return bcrypt.checkpw(password.encode(), self.password_hash.encode())
# 旧格式SHA-256
if self.password_hash == hashlib.sha256(password.encode()).hexdigest():
# 自动升级为 bcrypt
self.set_password(password)
return True
return False
def to_dict(self):
"""转换为字典格式用于API响应"""

View File

@@ -161,50 +161,20 @@ class FeishuLongConnService:
self._reply_message(message_id, "您好!请问有什么可以帮助您的吗?")
return
logger.info(f" 清理后内容: {text_content}")
logger.info(f"清理后内容: {text_content}")
# 构造会话用户ID群聊隔离
# 使用 Pipeline 统一处理消息
session_user_id = f"feishu_{chat_id}_{sender_id}"
logger.info(f" 会话标识: {session_user_id}")
# 解析租户:根据 chat_id 查找绑定的租户
from src.web.blueprints.tenants import resolve_tenant_by_chat_id
tenant_id = resolve_tenant_by_chat_id(chat_id)
logger.info(f"{chat_id} 对应租户: {tenant_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')
# 更新会话的 tenant_id群可能重新绑定了租户
if session_id in chat_manager.active_sessions:
chat_manager.active_sessions[session_id]['tenant_id'] = tenant_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,
tenant_id=tenant_id
)
logger.info(f" 创建新会话: {session_id}, 用户={sender_name}({sender_id}), 租户={tenant_id}")
# 调用实时对话接口处理消息
response_data = chat_manager.process_message(
session_id=session_id,
user_message=text_content,
pipeline = service_manager.get_pipeline()
response_data = pipeline.handle_message(
user_id=session_user_id,
message=text_content,
chat_id=chat_id,
ip_address=f"feishu:{sender_id}:{sender_name}",
invocation_method=f"feishu_longconn({chat_type})"
)
logger.info(f" 处理结果: {response_data.get('success')}")
tenant_id = response_data.get('tenant_id', 'default')
logger.info(f"处理结果: success={response_data.get('success')}, 租户={tenant_id}")
# 提取回复
if response_data.get("success"):

View File

@@ -99,59 +99,36 @@ def _process_message_in_background(app, event_data: dict):
logger.info(f"[Feishu Bot] 清理后的消息内容: '{text_content}'")
# 3. 获取或创建该飞书用户的会话(支持群聊隔离)
chat_manager = service_manager.get_chat_manager()
# 获取发送者ID优先 open_iduser_id 可能为 None
# 3. 解析发送者信息
sender = event.get('sender', {})
sender_ids = sender.get('sender_id', {})
sender_open_id = sender_ids.get('open_id', '') or ''
sender_user_id = sender_ids.get('user_id', '') or ''
sender_id = sender_user_id or sender_open_id or 'unknown'
sender_type = sender.get('sender_type', 'user')
# 获取发送者姓名(用 open_id 查询)
sender_name = sender_id
try:
id_for_query = sender_open_id or sender_user_id
id_type = 'open_id' if sender_open_id else 'user_id'
if id_for_query:
user_info = feishu_service.get_user_info(id_for_query, id_type=id_type)
user_info = feishu_service.get_user_info(id_for_query, id_type='open_id' if sender_open_id else 'user_id')
sender_name = user_info.get('name') or user_info.get('en_name') or sender_id
except Exception as e:
logger.warning(f"[Feishu Bot] 获取发送者信息失败: {e}")
except Exception:
pass
logger.info(f"[Feishu Bot] 消息详情: 发送者={sender_name}({sender_id}), 群={chat_id}, 类型={chat_type_desc}, 租户={tenant_id}, 消息ID={message_id}")
logger.info(f"[Feishu Bot] 📝 消息内容: '{text_content}'")
# 群聊隔离:每个用户在每个群都有独立会话
user_id = f"feishu_{chat_id}_{sender_id}"
logger.info(f"[Feishu Bot] 发送者={sender_name}({sender_id}), 群={chat_id}, 租户={tenant_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')
# 更新会话的 tenant_id群可能重新绑定了租户
if session_id in chat_manager.active_sessions:
chat_manager.active_sessions[session_id]['tenant_id'] = tenant_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, tenant_id=tenant_id)
logger.info(f"[Feishu Bot] 新建会话: {session_id}, 用户={sender_name}({sender_id}), 租户={tenant_id}")
# 4. 调用实时对话接口处理消息
response_data = chat_manager.process_message(
session_id=session_id,
user_message=text_content,
# 4. 使用 Pipeline 统一处理消息
pipeline = service_manager.get_pipeline()
response_data = pipeline.handle_message(
user_id=user_id,
message=text_content,
tenant_id=tenant_id,
chat_id=chat_id,
ip_address=f"feishu:{sender_id}:{sender_name}",
invocation_method=f"feishu_bot({chat_type})"
)
logger.info(f"[Feishu Bot] 处理结果: success={response_data.get('success')}, 用户={sender_name}")
logger.info(f"[Feishu Bot] 处理结果: success={response_data.get('success')}")
# 5. 提取回复并发送
if response_data.get("success"):

View File

@@ -69,6 +69,8 @@ function updatePageLanguage(lang) {
class TSPDashboard {
constructor() {
// ===== 事件总线(模块间通信)=====
this._eventHandlers = {};
// ===== 共享状态(所有模块通过 this.xxx 访问)=====
// 导航
this.currentTab = 'dashboard';
@@ -116,6 +118,10 @@ class TSPDashboard {
updatePageLanguage(this.currentLanguage);
}
// ===== 事件总线 =====
on(event, handler) { (this._eventHandlers[event] = this._eventHandlers[event] || []).push(handler); }
off(event, handler) { const h = this._eventHandlers[event]; if (h) this._eventHandlers[event] = h.filter(fn => fn !== handler); }
emit(event, data) { (this._eventHandlers[event] || []).forEach(fn => { try { fn(data); } catch (e) { console.error(`Event ${event} handler error:`, e); } }); }
async applyModulePermissions() {
try {
const resp = await fetch('/api/settings');