feat: 飞书机器人按租户路由 群组绑定租户 + 独立凭证 + 知识库隔离

1. 新增 resolve_tenant_by_chat_id() 根据飞书群 chat_id 查找绑定的租户
2. 新增 get_tenant_feishu_config() 获取租户级飞书凭证
3. FeishuService 支持传入自定义 app_id/app_secret(租户级别)
4. feishu_bot.py 收到消息时自动解析租户,使用租户凭证回复
5. feishu_longconn_service.py 同样按 chat_id 解析租户并传递 tenant_id
6. 租户管理 UI 新增飞书配置字段:App ID、App Secret、绑定群 Chat ID
7. 租户列表展示飞书绑定状态和群数量
8. 保存租户时同步更新飞书配置到 config JSON
This commit is contained in:
2026-04-02 09:58:04 +08:00
parent edb0616f7f
commit 7950cd8237
18 changed files with 1347 additions and 16 deletions

View File

@@ -27,7 +27,7 @@ def _process_message_in_background(app, event_data: dict):
"""
with app.app_context():
# 每个线程创建独立的飞书服务实例,避免token共享问题
feishu_service = FeishuService()
from src.web.blueprints.tenants import resolve_tenant_by_chat_id, get_tenant_feishu_config
try:
# 1. 解析事件数据
@@ -38,6 +38,21 @@ def _process_message_in_background(app, event_data: dict):
chat_id = message.get('chat_id')
chat_type = message.get('chat_type', 'unknown')
if not message_id or not chat_id:
logger.error(f"[Feishu Bot] 事件数据缺少必要字段: {event_data}")
return
# 解析租户:根据 chat_id 查找绑定的租户
tenant_id = resolve_tenant_by_chat_id(chat_id)
logger.info(f"[Feishu Bot] 群 {chat_id} 对应租户: {tenant_id}")
# 获取租户级飞书凭证(如果配置了)
tenant_feishu_cfg = get_tenant_feishu_config(tenant_id)
feishu_service = FeishuService(
app_id=tenant_feishu_cfg.get('app_id'),
app_secret=tenant_feishu_cfg.get('app_secret')
)
if not message_id or not chat_id:
logger.error(f"[Feishu Bot] 事件数据缺少必要字段: {event_data}")
return
@@ -107,8 +122,8 @@ def _process_message_in_background(app, event_data: dict):
# 如果没有会话,创建新会话
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}")
session_id = chat_manager.create_session(user_id=user_id, work_order_id=None, tenant_id=tenant_id)
logger.info(f"[Feishu Bot] 为用户 {sender_id} 在群聊 {chat_id} 创建新会话: {session_id} (租户: {tenant_id})")
# 4. 调用实时对话接口处理消息
logger.info(f"[Feishu Bot] 调用实时对话接口处理消息...")

View File

@@ -130,3 +130,45 @@ def delete_tenant(tenant_id):
except Exception as e:
logger.error(f"删除租户失败: {e}")
return jsonify({"error": str(e)}), 500
def resolve_tenant_by_chat_id(chat_id: str) -> str:
"""
根据飞书 chat_id 查找对应的 tenant_id。
遍历所有租户的 config.feishu.chat_groups匹配则返回该 tenant_id。
未匹配时返回 DEFAULT_TENANT。
"""
try:
with db_manager.get_session() as session:
tenants = session.query(Tenant).filter(Tenant.is_active == True).all()
for t in tenants:
if not t.config:
continue
try:
cfg = json.loads(t.config)
except (json.JSONDecodeError, TypeError):
continue
feishu_cfg = cfg.get('feishu', {})
chat_groups = feishu_cfg.get('chat_groups', [])
if chat_id in chat_groups:
logger.info(f"飞书群 {chat_id} 匹配到租户 {t.tenant_id}")
return t.tenant_id
except Exception as e:
logger.error(f"解析飞书群租户映射失败: {e}")
return DEFAULT_TENANT
def get_tenant_feishu_config(tenant_id: str) -> dict:
"""
获取租户的飞书配置。
返回 {'app_id': ..., 'app_secret': ..., 'chat_groups': [...]} 或空字典。
"""
try:
with db_manager.get_session() as session:
tenant = session.query(Tenant).filter(Tenant.tenant_id == tenant_id).first()
if tenant and tenant.config:
cfg = json.loads(tenant.config)
return cfg.get('feishu', {})
except Exception as e:
logger.error(f"获取租户飞书配置失败: {e}")
return {}

View File

@@ -6899,7 +6899,11 @@ class TSPDashboard {
return;
}
container.innerHTML = tenants.map(t => `
container.innerHTML = tenants.map(t => {
const feishuCfg = t.config?.feishu || {};
const groupCount = (feishuCfg.chat_groups || []).length;
const hasFeishu = feishuCfg.app_id || groupCount > 0;
return `
<div class="card mb-2">
<div class="card-body d-flex justify-content-between align-items-center py-2">
<div>
@@ -6907,6 +6911,7 @@ class TSPDashboard {
<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>' : ''}
${hasFeishu ? `<span class="badge bg-info ms-2"><i class="fas fa-robot me-1"></i>飞书${groupCount > 0 ? ` (${groupCount}群)` : ''}</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, "\\'")}')">
@@ -6919,7 +6924,7 @@ class TSPDashboard {
</div>
</div>
</div>
`).join('');
`}).join('');
} catch (error) {
console.error('加载租户列表失败:', error);
container.innerHTML = '<div class="text-center py-4 text-danger">加载失败</div>';
@@ -6934,16 +6939,35 @@ class TSPDashboard {
document.getElementById('tenant-id-group').style.display = '';
document.getElementById('tenant-name-input').value = '';
document.getElementById('tenant-desc-input').value = '';
document.getElementById('tenant-feishu-appid').value = '';
document.getElementById('tenant-feishu-appsecret').value = '';
document.getElementById('tenant-feishu-chatgroups').value = '';
new bootstrap.Modal(document.getElementById('tenantModal')).show();
}
showEditTenantModal(tenantId, name, description) {
async 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;
// 加载租户的飞书配置
try {
const resp = await fetch('/api/tenants');
const tenants = await resp.json();
const tenant = tenants.find(t => t.tenant_id === tenantId);
const feishuCfg = tenant?.config?.feishu || {};
document.getElementById('tenant-feishu-appid').value = feishuCfg.app_id || '';
document.getElementById('tenant-feishu-appsecret').value = feishuCfg.app_secret || '';
document.getElementById('tenant-feishu-chatgroups').value = (feishuCfg.chat_groups || []).join('\n');
} catch (e) {
document.getElementById('tenant-feishu-appid').value = '';
document.getElementById('tenant-feishu-appsecret').value = '';
document.getElementById('tenant-feishu-chatgroups').value = '';
}
new bootstrap.Modal(document.getElementById('tenantModal')).show();
}
@@ -6953,6 +6977,20 @@ class TSPDashboard {
const name = document.getElementById('tenant-name-input').value.trim();
const description = document.getElementById('tenant-desc-input').value.trim();
// 飞书配置
const feishuAppId = document.getElementById('tenant-feishu-appid').value.trim();
const feishuAppSecret = document.getElementById('tenant-feishu-appsecret').value.trim();
const chatGroupsText = document.getElementById('tenant-feishu-chatgroups').value.trim();
const chatGroups = chatGroupsText ? chatGroupsText.split('\n').map(s => s.trim()).filter(Boolean) : [];
const config = {};
if (feishuAppId || feishuAppSecret || chatGroups.length > 0) {
config.feishu = {};
if (feishuAppId) config.feishu.app_id = feishuAppId;
if (feishuAppSecret) config.feishu.app_secret = feishuAppSecret;
if (chatGroups.length > 0) config.feishu.chat_groups = chatGroups;
}
if (!name) {
this.showNotification('租户名称不能为空', 'error');
return;
@@ -6961,14 +6999,12 @@ class TSPDashboard {
try {
let response;
if (editId) {
// 编辑
response = await fetch(`/api/tenants/${encodeURIComponent(editId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
body: JSON.stringify({ name, description, config })
});
} else {
// 新建
if (!tenantId) {
this.showNotification('租户标识不能为空', 'error');
return;
@@ -6976,7 +7012,7 @@ class TSPDashboard {
response = await fetch('/api/tenants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenant_id: tenantId, name, description })
body: JSON.stringify({ tenant_id: tenantId, name, description, config })
});
}
@@ -6985,6 +7021,7 @@ class TSPDashboard {
bootstrap.Modal.getInstance(document.getElementById('tenantModal')).hide();
this.showNotification(editId ? '租户已更新' : '租户已创建', 'success');
this.loadTenantList();
this.populateTenantSelectors();
} else {
this.showNotification(data.error || '操作失败', 'error');
}

View File

@@ -2259,6 +2259,21 @@
<label class="form-label">描述</label>
<textarea class="form-control" id="tenant-desc-input" rows="2" placeholder="可选"></textarea>
</div>
<hr>
<h6 class="text-muted">飞书配置(可选)</h6>
<div class="mb-3">
<label class="form-label">飞书 App ID</label>
<input type="text" class="form-control" id="tenant-feishu-appid" placeholder="留空则使用全局配置">
</div>
<div class="mb-3">
<label class="form-label">飞书 App Secret</label>
<input type="password" class="form-control" id="tenant-feishu-appsecret" placeholder="留空则使用全局配置">
</div>
<div class="mb-3">
<label class="form-label">绑定的飞书群 Chat ID每行一个</label>
<textarea class="form-control" id="tenant-feishu-chatgroups" rows="3" placeholder="oc_xxxxxxxx&#10;oc_yyyyyyyy"></textarea>
<div class="form-text">将飞书群绑定到此租户,机器人在该群收到消息时自动使用此租户的知识库</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>