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:
@@ -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,
|
||||
|
||||
@@ -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']} 条"
|
||||
|
||||
132
src/web/blueprints/tenants.py
Normal file
132
src/web/blueprints/tenants.py
Normal 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
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
# 清除工单相关缓存
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 飞书同步管理器
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user