feat: 租户管理体系建设 CRUD + 各业务模块接入 tenant_id

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. 初始化时自动加载租户列表填充选择器
This commit is contained in:
2026-04-02 09:33:16 +08:00
parent 7013e9db70
commit edb0616f7f
14 changed files with 465 additions and 15 deletions

View File

@@ -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,

View File

@@ -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']}"

View File

@@ -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('/<tenant_id>', 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('/<tenant_id>', 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

View File

@@ -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')
)
# 清除工单相关缓存

View File

@@ -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 =>
`<option value="${t.tenant_id}"${t.tenant_id === currentVal ? ' selected' : ''}>${t.name} (${t.tenant_id})</option>`
).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 = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
try {
const response = await fetch('/api/tenants');
const tenants = await response.json();
if (!Array.isArray(tenants) || tenants.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-muted">暂无租户,请点击"新建租户"创建</div>';
return;
}
container.innerHTML = tenants.map(t => `
<div class="card mb-2">
<div class="card-body d-flex justify-content-between align-items-center py-2">
<div>
<strong>${t.name}</strong>
<span class="text-muted ms-2">(${t.tenant_id})</span>
${t.description ? `<br><small class="text-muted">${t.description}</small>` : ''}
${!t.is_active ? '<span class="badge bg-secondary ms-2">已禁用</span>' : ''}
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="dashboard.showEditTenantModal('${t.tenant_id}', '${(t.name || '').replace(/'/g, "\\'")}', '${(t.description || '').replace(/'/g, "\\'")}')">
<i class="fas fa-edit"></i>
</button>
${t.tenant_id !== 'default' ? `
<button class="btn btn-outline-danger" onclick="dashboard.deleteTenant('${t.tenant_id}')">
<i class="fas fa-trash"></i>
</button>` : ''}
</div>
</div>
</div>
`).join('');
} catch (error) {
console.error('加载租户列表失败:', error);
container.innerHTML = '<div class="text-center py-4 text-danger">加载失败</div>';
}
}
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');
}
}
}
// 飞书同步管理器

View File

@@ -439,6 +439,10 @@
<i class="fas fa-sliders-h"></i>
系统设置
</a>
<a class="nav-link" href="#tenant-management" data-tab="tenant-management">
<i class="fas fa-building"></i>
租户管理
</a>
</nav>
</div>
</div>
@@ -568,6 +572,13 @@
<h5><i class="fas fa-cog me-2"></i>对话控制</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">租户</label>
<select class="form-select" id="chat-tenant-id">
<option value="default">默认租户</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">用户ID</label>
<input type="text" class="form-control" id="user-id" value="user_001">
@@ -2187,6 +2198,76 @@
</div>
<!-- 模态框 -->
<!-- 租户管理标签页 -->
<div id="tenant-management-tab" class="tab-content" style="display: none;">
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="fas fa-building me-2"></i>租户管理</h5>
<button class="btn btn-primary btn-sm" onclick="dashboard.showCreateTenantModal()">
<i class="fas fa-plus me-1"></i>新建租户
</button>
</div>
<div class="card-body">
<div id="tenant-list">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5><i class="fas fa-info-circle me-2"></i>说明</h5>
</div>
<div class="card-body">
<p class="text-muted">租户代表不同的市场或业务单元。每个租户拥有独立的知识库、对话历史和工单数据。</p>
<ul class="text-muted small">
<li>创建租户后,可在各业务模块中选择租户</li>
<li>默认租户不可删除</li>
<li>可为每个租户配置独立的飞书应用和机器人</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 创建/编辑租户模态框 -->
<div class="modal fade" id="tenantModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="tenantModalTitle">新建租户</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="tenant-edit-id">
<div class="mb-3" id="tenant-id-group">
<label class="form-label">租户标识 (tenant_id)</label>
<input type="text" class="form-control" id="tenant-id-input" placeholder="如 market_a创建后不可修改">
<div class="form-text">唯一标识,建议使用英文和下划线</div>
</div>
<div class="mb-3">
<label class="form-label">租户名称</label>
<input type="text" class="form-control" id="tenant-name-input" placeholder="如 市场A">
</div>
<div class="mb-3">
<label class="form-label">描述</label>
<textarea class="form-control" id="tenant-desc-input" rows="2" placeholder="可选"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="dashboard.saveTenant()">保存</button>
</div>
</div>
</div>
</div>
<!-- 创建工单模态框 -->
<div class="modal fade" id="createWorkOrderModal" tabindex="-1">
<div class="modal-dialog">

View File

@@ -67,8 +67,9 @@ class WebSocketServer:
"""处理创建会话请求"""
user_id = data.get("user_id", "anonymous")
work_order_id = data.get("work_order_id")
tenant_id = data.get("tenant_id")
session_id = self.chat_manager.create_session(user_id, work_order_id)
session_id = self.chat_manager.create_session(user_id, work_order_id, tenant_id=tenant_id)
response = {
"type": "session_created",