From edb0616f7f5904e37805975261b96ff532f2e477 Mon Sep 17 00:00:00 2001 From: Jeason <1710884619@qq.com> Date: Thu, 2 Apr 2026 09:33:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=9F=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=E5=BB=BA=E8=AE=BE=20=20CRUD=20+=20=E5=90=84?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=A8=A1=E5=9D=97=E6=8E=A5=E5=85=A5=20tenant?= =?UTF-8?q?=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 Tenant 模型(tenants 表),支持租户创建、重命名、删除 2. 新增 /api/tenants CRUD 蓝图,default 租户不可删除 3. 数据库初始化时自动创建默认租户记录 4. Dashboard 新增租户管理标签页(创建/编辑/删除租户) 5. 各业务模块写入数据时正确传递 tenant_id: - realtime_chat: create_session 和 _save_conversation 支持 tenant_id - dialogue_manager: _save_conversation 和 create_work_order 支持 tenant_id - conversation_history: save_conversation 支持 tenant_id - workorder_sync: sync_from_feishu 支持 tenant_id - websocket_server: create_session 传递 tenant_id - HTTP chat API: create_session 传递 tenant_id - feishu_sync API: 同步时传递 tenant_id - workorders API: 创建工单时传递 tenant_id 6. 网页对话入口添加租户选择器 7. 知识库搜索按租户隔离(realtime_chat 中 _search_knowledge 传递 tenant_id) 8. 初始化时自动加载租户列表填充选择器 --- data/tsp_assistant.db | Bin 196608 -> 196608 bytes src/core/database.py | 25 +++++ src/core/models.py | 29 +++++ src/dialogue/conversation_history.py | 5 +- src/dialogue/dialogue_manager.py | 10 +- src/dialogue/realtime_chat.py | 24 ++++- src/integrations/workorder_sync.py | 4 +- src/web/app.py | 5 +- src/web/blueprints/feishu_sync.py | 3 +- src/web/blueprints/tenants.py | 132 +++++++++++++++++++++++ src/web/blueprints/workorders.py | 3 +- src/web/static/js/dashboard.js | 156 ++++++++++++++++++++++++++- src/web/templates/dashboard.html | 81 ++++++++++++++ src/web/websocket_server.py | 3 +- 14 files changed, 465 insertions(+), 15 deletions(-) create mode 100644 src/web/blueprints/tenants.py diff --git a/data/tsp_assistant.db b/data/tsp_assistant.db index 3bc0a5dd138209ee1385a59be451ed3e6d27d2db..1d24dd3b0f4da61b77aeb112dcc3fce39ef93343 100644 GIT binary patch delta 1242 zcmb8ty-QnB7{~GC#%Nxy2^l(Qga#yoFF8Ih=bU@atr4V1#lg)zf=Y)jeGw5H0)ib> zVsYy%bSsF1-AX(AA9U&;;4A?Hz017~H_OQ%@ZtCH<>_|)Zr8u6X9r*AUS|ilo%3EL z2q_392r{flpZ0s7`@QUazvq=Zt8?B%rg~YOt$eG<>0i^^{vZEs`LX<=^i(=2Ws7IU za^b4*V(RPE%gLL`h5YwCPyCo@=YHqjcn^2A#)VfoUh^~U`TER8rnsJ|U)PQ{Sqsx7 zAl(Al0&HNkP9d;R78&*4{=x3yyHCe$cV{f4jNPic(~42W?v=BXRx+B{h3?Kod&!;F z-BEys;yp9stl)G{41t7{Qb~?o;O+=C1+mla4kDNIRL+h~pftpL(%nUjlP>J;l#zh( z9*wv_V7e!SfP$H{O}gM14u%{!JF+tPf9Hz3BdcxlpTgN8>Y+PZW4Pfi%PF^okgCZx k1P}m7CaJlUR?QJ*LS= Generator[Session, None, None]: """获取数据库会话的上下文管理器""" diff --git a/src/core/models.py b/src/core/models.py index a0f8bd3..792bdb4 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -9,6 +9,35 @@ Base = declarative_base() # 默认租户ID,单租户部署时使用 DEFAULT_TENANT = "default" + +class Tenant(Base): + """租户模型 — 管理多租户(市场)""" + __tablename__ = "tenants" + + id = Column(Integer, primary_key=True) + tenant_id = Column(String(50), unique=True, nullable=False) # 唯一标识,如 market_a + name = Column(String(100), nullable=False) # 显示名称,如 "市场A" + description = Column(Text, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # 租户级配置:飞书 app 凭证、LLM 参数等(JSON 格式) + config = Column(Text, nullable=True) + + def to_dict(self): + import json + return { + 'id': self.id, + 'tenant_id': self.tenant_id, + 'name': self.name, + 'description': self.description, + 'is_active': self.is_active, + 'config': json.loads(self.config) if self.config else {}, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + class WorkOrder(Base): """工单模型""" __tablename__ = "work_orders" diff --git a/src/dialogue/conversation_history.py b/src/dialogue/conversation_history.py index 87db460..88e64da 100644 --- a/src/dialogue/conversation_history.py +++ b/src/dialogue/conversation_history.py @@ -46,16 +46,19 @@ class ConversationHistoryManager: knowledge_used: Optional[List[int]] = None, ip_address: Optional[str] = None, invocation_method: Optional[str] = None, - session_id: Optional[str] = None + session_id: Optional[str] = None, + tenant_id: Optional[str] = None ) -> int: """保存对话记录到数据库和Redis""" conversation_id = 0 try: + from src.core.models import DEFAULT_TENANT # 保存到数据库 with db_manager.get_session() as session: conversation = Conversation( session_id=session_id, + tenant_id=tenant_id or DEFAULT_TENANT, work_order_id=work_order_id, user_message=user_message, assistant_response=assistant_response, diff --git a/src/dialogue/dialogue_manager.py b/src/dialogue/dialogue_manager.py index d4b3da6..40a0bce 100644 --- a/src/dialogue/dialogue_manager.py +++ b/src/dialogue/dialogue_manager.py @@ -273,13 +273,16 @@ class DialogueManager: work_order_id: Optional[int], user_message: str, assistant_response: str, - knowledge_used: str + knowledge_used: str, + tenant_id: Optional[str] = None ) -> int: """保存对话记录""" try: + from src.core.models import DEFAULT_TENANT with db_manager.get_session() as session: conversation = Conversation( work_order_id=work_order_id, + tenant_id=tenant_id or DEFAULT_TENANT, user_message=user_message, assistant_response=assistant_response, knowledge_used=knowledge_used, @@ -310,10 +313,12 @@ class DialogueManager: title: str, description: str, category: str, - priority: str = "medium" + priority: str = "medium", + tenant_id: Optional[str] = None ) -> Dict[str, Any]: """创建工单""" try: + from src.core.models import DEFAULT_TENANT with db_manager.get_session() as session: work_order = WorkOrder( order_id=f"WO{datetime.now().strftime('%Y%m%d%H%M%S')}", @@ -322,6 +327,7 @@ class DialogueManager: category=category, priority=priority, status="open", + tenant_id=tenant_id or DEFAULT_TENANT, created_at=datetime.now() ) session.add(work_order) diff --git a/src/dialogue/realtime_chat.py b/src/dialogue/realtime_chat.py index 19fd244..5ed3462 100644 --- a/src/dialogue/realtime_chat.py +++ b/src/dialogue/realtime_chat.py @@ -39,13 +39,14 @@ class RealtimeChatManager: self.active_sessions = {} # 存储活跃的对话会话 self.message_history = {} # 存储消息历史 - def create_session(self, user_id: str, work_order_id: Optional[int] = None) -> str: + def create_session(self, user_id: str, work_order_id: Optional[int] = None, tenant_id: Optional[str] = None) -> str: """创建新的对话会话""" session_id = f"session_{user_id}_{int(time.time())}" session_data = { "user_id": user_id, "work_order_id": work_order_id, + "tenant_id": tenant_id, "created_at": datetime.now(), "last_activity": datetime.now(), "message_count": 0, @@ -57,11 +58,14 @@ class RealtimeChatManager: # 持久化会话到数据库 try: + from src.core.models import DEFAULT_TENANT + effective_tenant = tenant_id or DEFAULT_TENANT with db_manager.get_session() as db_session: chat_session = ChatSession( session_id=session_id, user_id=user_id, work_order_id=work_order_id, + tenant_id=effective_tenant, status="active", message_count=0, ) @@ -96,8 +100,9 @@ class RealtimeChatManager: # 添加到消息历史 self.message_history[session_id].append(user_msg) - # 搜索相关知识 - knowledge_results = self._search_knowledge(user_message) + # 搜索相关知识(按租户隔离) + session_tenant = session.get("tenant_id") + knowledge_results = self._search_knowledge(user_message, tenant_id=session_tenant) # 识别VIN并查询实时数据,注入上下文 vin = self._extract_vin(user_message) @@ -180,10 +185,10 @@ class RealtimeChatManager: logger.error(f"处理消息失败: {e}") return {"error": f"处理消息失败: {str(e)}"} - def _search_knowledge(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]: + def _search_knowledge(self, query: str, top_k: int = 3, tenant_id: str = None) -> List[Dict[str, Any]]: """搜索相关知识""" try: - results = self.knowledge_manager.search_knowledge(query, top_k) + results = self.knowledge_manager.search_knowledge(query, top_k, tenant_id=tenant_id) return results except Exception as e: logger.error(f"搜索知识库失败: {e}") @@ -474,8 +479,17 @@ class RealtimeChatManager: # 保存知识库使用记录(不再塞 session_marker) knowledge_data = assistant_msg.knowledge_used or [] + # 获取会话的 tenant_id + session_tenant = None + if session_id in self.active_sessions: + session_tenant = self.active_sessions[session_id].get('tenant_id') + if not session_tenant: + from src.core.models import DEFAULT_TENANT + session_tenant = DEFAULT_TENANT + conversation = Conversation( session_id=session_id, + tenant_id=session_tenant, work_order_id=assistant_msg.work_order_id or user_msg.work_order_id, user_message=user_msg.content or "", assistant_response=assistant_msg.content or "", diff --git a/src/integrations/workorder_sync.py b/src/integrations/workorder_sync.py index 6565a8d..3c49a1c 100644 --- a/src/integrations/workorder_sync.py +++ b/src/integrations/workorder_sync.py @@ -124,7 +124,7 @@ class WorkOrderSyncService: """获取映射状态""" return self.field_mapper.get_mapping_status() - def sync_from_feishu(self, generate_ai_suggestions: bool = True, limit: int = 10) -> Dict[str, Any]: + def sync_from_feishu(self, generate_ai_suggestions: bool = True, limit: int = 10, tenant_id: str = None) -> Dict[str, Any]: """ 从飞书同步工单数据到本地系统 @@ -215,6 +215,8 @@ class WorkOrderSyncService: # 创建新记录 valid_fields["created_at"] = datetime.now() valid_fields["updated_at"] = datetime.now() + if tenant_id: + valid_fields["tenant_id"] = tenant_id new_workorder = WorkOrder(**valid_fields) session.add(new_workorder) created_count += 1 diff --git a/src/web/app.py b/src/web/app.py index 2e16218..51de5e9 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -38,6 +38,7 @@ from src.web.blueprints.vehicle import vehicle_bp from src.web.blueprints.analytics import analytics_bp from src.web.blueprints.test import test_bp from src.web.blueprints.feishu_bot import feishu_bot_bp +from src.web.blueprints.tenants import tenants_bp # 配置日志 @@ -126,6 +127,7 @@ app.register_blueprint(vehicle_bp) app.register_blueprint(analytics_bp) app.register_blueprint(test_bp) app.register_blueprint(feishu_bot_bp) +app.register_blueprint(tenants_bp) # 页面路由 @@ -180,8 +182,9 @@ def create_chat_session(): data = request.get_json() user_id = data.get('user_id', 'anonymous') work_order_id = data.get('work_order_id') + tenant_id = data.get('tenant_id') - session_id = service_manager.get_chat_manager().create_session(user_id, work_order_id) + session_id = service_manager.get_chat_manager().create_session(user_id, work_order_id, tenant_id=tenant_id) return jsonify({ "success": True, diff --git a/src/web/blueprints/feishu_sync.py b/src/web/blueprints/feishu_sync.py index f0b3f10..dc70c38 100644 --- a/src/web/blueprints/feishu_sync.py +++ b/src/web/blueprints/feishu_sync.py @@ -92,9 +92,10 @@ def sync_from_feishu(): data = request.get_json() or {} generate_ai = data.get('generate_ai_suggestions', True) limit = data.get('limit', 10) + tenant_id = data.get('tenant_id') sync_service = get_sync_service() - result = sync_service.sync_from_feishu(generate_ai_suggestions=generate_ai, limit=limit) + result = sync_service.sync_from_feishu(generate_ai_suggestions=generate_ai, limit=limit, tenant_id=tenant_id) if result.get("success"): message = f"同步完成:创建 {result['created_count']} 条,更新 {result['updated_count']} 条" diff --git a/src/web/blueprints/tenants.py b/src/web/blueprints/tenants.py new file mode 100644 index 0000000..d8c1547 --- /dev/null +++ b/src/web/blueprints/tenants.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +""" +租户管理蓝图 +处理租户 CRUD 的 API 路由 +""" + +import json +import logging +from flask import Blueprint, request, jsonify +from src.core.database import db_manager +from src.core.models import Tenant, DEFAULT_TENANT + +logger = logging.getLogger(__name__) + +tenants_bp = Blueprint('tenants', __name__, url_prefix='/api/tenants') + + +def _ensure_default_tenant(): + """确保 default 租户存在""" + try: + with db_manager.get_session() as session: + existing = session.query(Tenant).filter(Tenant.tenant_id == DEFAULT_TENANT).first() + if not existing: + session.add(Tenant( + tenant_id=DEFAULT_TENANT, + name="默认租户", + description="系统默认租户" + )) + session.commit() + except Exception as e: + logger.error(f"确保默认租户失败: {e}") + + +@tenants_bp.route('', methods=['GET']) +def list_tenants(): + """获取所有租户列表""" + try: + with db_manager.get_session() as session: + tenants = session.query(Tenant).order_by(Tenant.created_at).all() + return jsonify([t.to_dict() for t in tenants]) + except Exception as e: + logger.error(f"获取租户列表失败: {e}") + return jsonify({"error": str(e)}), 500 + + +@tenants_bp.route('', methods=['POST']) +def create_tenant(): + """创建新租户""" + try: + data = request.get_json() + if not data or not data.get('tenant_id') or not data.get('name'): + return jsonify({"error": "tenant_id 和 name 为必填项"}), 400 + + tenant_id = data['tenant_id'].strip() + name = data['name'].strip() + + if not tenant_id or not name: + return jsonify({"error": "tenant_id 和 name 不能为空"}), 400 + + with db_manager.get_session() as session: + existing = session.query(Tenant).filter(Tenant.tenant_id == tenant_id).first() + if existing: + return jsonify({"error": f"租户 '{tenant_id}' 已存在"}), 409 + + tenant = Tenant( + tenant_id=tenant_id, + name=name, + description=data.get('description', ''), + config=json.dumps(data.get('config', {})) + ) + session.add(tenant) + session.commit() + + # 重新查询以获取完整数据 + tenant = session.query(Tenant).filter(Tenant.tenant_id == tenant_id).first() + return jsonify({"success": True, "tenant": tenant.to_dict()}), 201 + + except Exception as e: + logger.error(f"创建租户失败: {e}") + return jsonify({"error": str(e)}), 500 + + +@tenants_bp.route('/', methods=['PUT']) +def update_tenant(tenant_id): + """更新租户信息(重命名等)""" + try: + data = request.get_json() + if not data: + return jsonify({"error": "请求体不能为空"}), 400 + + with db_manager.get_session() as session: + tenant = session.query(Tenant).filter(Tenant.tenant_id == tenant_id).first() + if not tenant: + return jsonify({"error": f"租户 '{tenant_id}' 不存在"}), 404 + + if 'name' in data: + tenant.name = data['name'].strip() + if 'description' in data: + tenant.description = data['description'] + if 'config' in data: + tenant.config = json.dumps(data['config']) + if 'is_active' in data: + tenant.is_active = data['is_active'] + + session.commit() + tenant = session.query(Tenant).filter(Tenant.tenant_id == tenant_id).first() + return jsonify({"success": True, "tenant": tenant.to_dict()}) + + except Exception as e: + logger.error(f"更新租户失败: {e}") + return jsonify({"error": str(e)}), 500 + + +@tenants_bp.route('/', methods=['DELETE']) +def delete_tenant(tenant_id): + """删除租户(不允许删除 default)""" + try: + if tenant_id == DEFAULT_TENANT: + return jsonify({"error": "不能删除默认租户"}), 403 + + with db_manager.get_session() as session: + tenant = session.query(Tenant).filter(Tenant.tenant_id == tenant_id).first() + if not tenant: + return jsonify({"error": f"租户 '{tenant_id}' 不存在"}), 404 + + session.delete(tenant) + session.commit() + return jsonify({"success": True, "message": f"租户 '{tenant_id}' 已删除"}) + + except Exception as e: + logger.error(f"删除租户失败: {e}") + return jsonify({"error": str(e)}), 500 diff --git a/src/web/blueprints/workorders.py b/src/web/blueprints/workorders.py index 0914304..30ca154 100644 --- a/src/web/blueprints/workorders.py +++ b/src/web/blueprints/workorders.py @@ -151,7 +151,8 @@ def create_workorder(): title=data['title'], description=data['description'], category=data['category'], - priority=data['priority'] + priority=data['priority'], + tenant_id=data.get('tenant_id') ) # 清除工单相关缓存 diff --git a/src/web/static/js/dashboard.js b/src/web/static/js/dashboard.js index 74d124d..306dae3 100644 --- a/src/web/static/js/dashboard.js +++ b/src/web/static/js/dashboard.js @@ -373,11 +373,30 @@ class TSPDashboard { } init() { this.bindEvents(); + this.populateTenantSelectors(); // 优化:并行加载初始数据,提高响应速度 this.loadInitialDataAsync(); this.startAutoRefresh(); this.initCharts(); } + + async populateTenantSelectors() { + try { + const response = await fetch('/api/tenants'); + const tenants = await response.json(); + if (!Array.isArray(tenants)) return; + + const selectors = document.querySelectorAll('#chat-tenant-id'); + selectors.forEach(select => { + const currentVal = select.value; + select.innerHTML = tenants.map(t => + `` + ).join(''); + }); + } catch (e) { + console.warn('加载租户列表失败:', e); + } + } async loadInitialDataAsync() { // 并行加载多个数据源 @@ -722,7 +741,7 @@ class TSPDashboard { this.loadWorkOrders(); break; case 'conversation-history': - this.loadConversationHistory(); + this.loadConversationTenantList(); break; case 'token-monitor': this.loadTokenMonitor(); @@ -739,6 +758,9 @@ class TSPDashboard { case 'settings': this.loadSettings(); break; + case 'tenant-management': + this.loadTenantList(); + break; } } @@ -1290,6 +1312,7 @@ class TSPDashboard { try { const userId = document.getElementById('user-id').value; const workOrderId = document.getElementById('work-order-id').value; + const tenantId = document.getElementById('chat-tenant-id')?.value || 'default'; const response = await fetch('/api/chat/session', { method: 'POST', @@ -1298,7 +1321,8 @@ class TSPDashboard { }, body: JSON.stringify({ user_id: userId, - work_order_id: workOrderId ? parseInt(workOrderId) : null + work_order_id: workOrderId ? parseInt(workOrderId) : null, + tenant_id: tenantId }) }); @@ -6858,6 +6882,134 @@ class TSPDashboard { this.showNotification('清空Agent历史失败: ' + error.message, 'error'); } } + + // ==================== 租户管理 ==================== + + async loadTenantList() { + const container = document.getElementById('tenant-list'); + if (!container) return; + container.innerHTML = '
'; + + try { + const response = await fetch('/api/tenants'); + const tenants = await response.json(); + + if (!Array.isArray(tenants) || tenants.length === 0) { + container.innerHTML = '
暂无租户,请点击"新建租户"创建
'; + return; + } + + container.innerHTML = tenants.map(t => ` +
+
+
+ ${t.name} + (${t.tenant_id}) + ${t.description ? `
${t.description}` : ''} + ${!t.is_active ? '已禁用' : ''} +
+
+ + ${t.tenant_id !== 'default' ? ` + ` : ''} +
+
+
+ `).join(''); + } catch (error) { + console.error('加载租户列表失败:', error); + container.innerHTML = '
加载失败
'; + } + } + + showCreateTenantModal() { + document.getElementById('tenantModalTitle').textContent = '新建租户'; + document.getElementById('tenant-edit-id').value = ''; + document.getElementById('tenant-id-input').value = ''; + document.getElementById('tenant-id-input').disabled = false; + document.getElementById('tenant-id-group').style.display = ''; + document.getElementById('tenant-name-input').value = ''; + document.getElementById('tenant-desc-input').value = ''; + new bootstrap.Modal(document.getElementById('tenantModal')).show(); + } + + showEditTenantModal(tenantId, name, description) { + document.getElementById('tenantModalTitle').textContent = '编辑租户'; + document.getElementById('tenant-edit-id').value = tenantId; + document.getElementById('tenant-id-input').value = tenantId; + document.getElementById('tenant-id-input').disabled = true; + document.getElementById('tenant-name-input').value = name; + document.getElementById('tenant-desc-input').value = description; + new bootstrap.Modal(document.getElementById('tenantModal')).show(); + } + + async saveTenant() { + const editId = document.getElementById('tenant-edit-id').value; + const tenantId = document.getElementById('tenant-id-input').value.trim(); + const name = document.getElementById('tenant-name-input').value.trim(); + const description = document.getElementById('tenant-desc-input').value.trim(); + + if (!name) { + this.showNotification('租户名称不能为空', 'error'); + return; + } + + try { + let response; + if (editId) { + // 编辑 + response = await fetch(`/api/tenants/${encodeURIComponent(editId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + } else { + // 新建 + if (!tenantId) { + this.showNotification('租户标识不能为空', 'error'); + return; + } + response = await fetch('/api/tenants', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenant_id: tenantId, name, description }) + }); + } + + const data = await response.json(); + if (data.success) { + bootstrap.Modal.getInstance(document.getElementById('tenantModal')).hide(); + this.showNotification(editId ? '租户已更新' : '租户已创建', 'success'); + this.loadTenantList(); + } else { + this.showNotification(data.error || '操作失败', 'error'); + } + } catch (error) { + console.error('保存租户失败:', error); + this.showNotification('保存租户失败: ' + error.message, 'error'); + } + } + + async deleteTenant(tenantId) { + if (!confirm(`确定要删除租户 "${tenantId}" 吗?该操作不会删除关联数据。`)) return; + try { + const response = await fetch(`/api/tenants/${encodeURIComponent(tenantId)}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + this.showNotification('租户已删除', 'success'); + this.loadTenantList(); + } else { + this.showNotification(data.error || '删除失败', 'error'); + } + } catch (error) { + console.error('删除租户失败:', error); + this.showNotification('删除租户失败: ' + error.message, 'error'); + } + } } // 飞书同步管理器 diff --git a/src/web/templates/dashboard.html b/src/web/templates/dashboard.html index bfd03d5..f312b46 100644 --- a/src/web/templates/dashboard.html +++ b/src/web/templates/dashboard.html @@ -439,6 +439,10 @@ 系统设置 + + + 租户管理 + @@ -568,6 +572,13 @@
对话控制
+
+ + +
+
@@ -2187,6 +2198,76 @@
+ + + + + +