mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 04:10:59 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b03e3395f | |||
| d3c443a2c8 | |||
| 4b2a628db6 | |||
| 610915b9c5 | |||
| e8d90c4259 | |||
| 2dfbe78271 | |||
| c89e6f3bd2 | |||
| e52d6880f5 | |||
| aa342d9347 | |||
| ae85ac2b16 | |||
| 32abbb636f | |||
| f247a9d183 | |||
| 624a197655 | |||
| 712f79ed77 | |||
| 602e10649b | |||
| d71bd571b1 | |||
| e90a1546de | |||
| ff068564ab | |||
| 94ff4fcd2d | |||
| 97b3e58884 | |||
| 66a1ceac25 | |||
| 8a445bfb22 | |||
| 276791e7af | |||
| baf7e86335 | |||
| 741c20af07 | |||
| 5ac1ab3eac | |||
| f96116a050 | |||
| 40abb03928 | |||
| 59f68b8fb4 | |||
| 7c64cd9d51 | |||
| 9ea1a81048 | |||
| d3f08a90b1 | |||
| 64e772e32d | |||
| 84440df47f | |||
| c860159446 | |||
| f977629a90 | |||
| ff13d52602 | |||
| 5beab49577 | |||
| e8a09b7537 | |||
| 98f45aa88e | |||
| d7cdd206c2 | |||
| ac72563664 | |||
| 64dc887b20 | |||
| 627eb6b8ef | |||
| 3f01ffe63b | |||
| d7adbeec1e | |||
| abf77cecfa | |||
| 270622ae9d | |||
| 30f414a534 | |||
| 8b7ce77cec | |||
| 37099ddf7e | |||
| ee59e2d3fd | |||
| a4550350c0 |
@@ -1,166 +0,0 @@
|
||||
# LangBot 多租户数据库迁移指南
|
||||
|
||||
## 概述
|
||||
|
||||
LangBot 从单租户 OSS 架构迁移到多租户 SaaS 架构,需要执行 7 个数据库迁移(0009-0015)。
|
||||
|
||||
## 迁移序列
|
||||
|
||||
```
|
||||
0009_workspace_tenancy_kernel → 创建 Workspace、成员、邀请表
|
||||
0010_scope_tenant_resources → 所有业务表添加 workspace_uuid
|
||||
0011_postgres_tenant_rls → PostgreSQL 行级安全策略
|
||||
0012_plugin_installation_identity → 插件实例租户绑定
|
||||
0013_tenant_pgvector → RAG 向量存储隔离
|
||||
0014_cloud_directory_projection → Cloud 控制平面同步
|
||||
0015_cloud_core_collaboration → 协作和权限功能
|
||||
```
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 1. 备份(必须)
|
||||
|
||||
```bash
|
||||
# SQLite
|
||||
cp ~/.langbot/data/langbot.db ~/.langbot/data/langbot.db.backup-$(date +%Y%m%d)
|
||||
|
||||
# PostgreSQL
|
||||
pg_dump -U langbot_user -d langbot_db -F c -f langbot_backup_$(date +%Y%m%d).dump
|
||||
```
|
||||
|
||||
### 2. 执行迁移
|
||||
|
||||
```bash
|
||||
# 停止服务
|
||||
sudo -S -p '' systemctl stop langbot
|
||||
|
||||
# 执行迁移
|
||||
python -m langbot.pkg.persistence.migration upgrade head
|
||||
|
||||
# 验证
|
||||
python -m langbot.pkg.persistence.migration current
|
||||
# 预期: 0015_cloud_core_collaboration
|
||||
|
||||
# 启动服务
|
||||
sudo -S -p '' systemctl start langbot
|
||||
```
|
||||
|
||||
### 3. OSS 单租户自动迁移
|
||||
|
||||
迁移会自动:
|
||||
- 创建默认 Workspace(名称:"Default Workspace")
|
||||
- 第一个用户成为 Owner
|
||||
- 所有现有资源绑定到该 Workspace
|
||||
|
||||
### 4. 验证检查
|
||||
|
||||
```bash
|
||||
# 检查 Workspace
|
||||
python << EOF
|
||||
from langbot.pkg.persistence import manager
|
||||
import sqlalchemy as sa
|
||||
|
||||
with manager.engine.connect() as conn:
|
||||
ws = conn.execute(sa.text("SELECT uuid, name FROM workspaces LIMIT 1")).first()
|
||||
print(f"Workspace: {ws[1]} ({ws[0]})")
|
||||
|
||||
# 检查资源绑定
|
||||
bot_count = conn.execute(sa.text(
|
||||
f"SELECT COUNT(*) FROM bots WHERE workspace_uuid='{ws[0]}'"
|
||||
)).scalar()
|
||||
print(f"Bots: {bot_count}")
|
||||
EOF
|
||||
```
|
||||
|
||||
## 回滚方案
|
||||
|
||||
### 完全回滚(丢失多租户数据)
|
||||
|
||||
```bash
|
||||
# 1. 停止服务
|
||||
sudo -S -p '' systemctl stop langbot
|
||||
|
||||
# 2. 恢复备份
|
||||
cp ~/.langbot/data/langbot.db.backup-YYYYMMDD ~/.langbot/data/langbot.db
|
||||
|
||||
# 3. 回退代码
|
||||
git checkout v4.10.x
|
||||
pip install -e .
|
||||
|
||||
# 4. 启动
|
||||
sudo -S -p '' systemctl start langbot
|
||||
```
|
||||
|
||||
### 降级迁移(保留数据但移除多租户)
|
||||
|
||||
```bash
|
||||
# 警告:会移除 Workspace 表但保留资源
|
||||
python -m langbot.pkg.persistence.migration downgrade 0008_mcp_resource_prefs
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 迁移后无法登录
|
||||
|
||||
```bash
|
||||
# 检查用户 UUID
|
||||
python << EOF
|
||||
from langbot.pkg.persistence import manager
|
||||
import sqlalchemy as sa
|
||||
|
||||
with manager.engine.connect() as conn:
|
||||
users = conn.execute(sa.text("SELECT id, user, uuid, status FROM users")).all()
|
||||
for u in users:
|
||||
print(f"{u[1]}: UUID={u[2]}, Status={u[3]}")
|
||||
EOF
|
||||
```
|
||||
|
||||
### Q: 资源看不见了
|
||||
|
||||
检查 Workspace 上下文:
|
||||
```bash
|
||||
# 前端请求需要带 X-Workspace-ID header
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
-H "X-Workspace-ID: $WORKSPACE_UUID" \
|
||||
http://localhost:5200/api/v1/platform/bots
|
||||
```
|
||||
|
||||
### Q: 迁移速度慢
|
||||
|
||||
```bash
|
||||
# SQLite 优化
|
||||
sqlite3 ~/.langbot/data/langbot.db << EOF
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
VACUUM;
|
||||
EOF
|
||||
```
|
||||
|
||||
## 性能调优
|
||||
|
||||
### PostgreSQL 索引
|
||||
|
||||
```sql
|
||||
-- 迁移后创建
|
||||
CREATE INDEX CONCURRENTLY idx_model_providers_workspace
|
||||
ON model_providers(workspace_uuid);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_bots_workspace
|
||||
ON bots(workspace_uuid);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_pipelines_workspace
|
||||
ON pipelines(workspace_uuid);
|
||||
```
|
||||
|
||||
## 预估时间
|
||||
|
||||
- SQLite < 100MB: 2-5 分钟
|
||||
- SQLite 100MB-1GB: 5-15 分钟
|
||||
- PostgreSQL: < 5 分钟(取决于数据量)
|
||||
|
||||
## 支持
|
||||
|
||||
问题反馈:https://github.com/langbot-app/LangBot/issues
|
||||
|
||||
**版本**: 1.0
|
||||
**最后更新**: 2026-07-30
|
||||
@@ -23,13 +23,8 @@ def _storage_owner(context: RequestContext) -> str:
|
||||
@group.group_class('files', '/api/v1/files')
|
||||
class FilesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route(
|
||||
'/image/<path:image_key>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(image_key: str, request_context: RequestContext) -> quart.Response:
|
||||
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _(image_key: str) -> quart.Response:
|
||||
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
||||
image_key,
|
||||
expected_owner_type='upload_image',
|
||||
|
||||
@@ -128,7 +128,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
token: str,
|
||||
) -> RequestContext:
|
||||
) -> None:
|
||||
"""Recheck revocable account, membership, permission, and placement state."""
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
@@ -168,7 +168,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
entitlement_revision=request_context.entitlement_revision,
|
||||
)
|
||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
||||
return current_context
|
||||
|
||||
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
|
||||
pipeline = await run_in_workspace_uow(
|
||||
|
||||
@@ -56,14 +56,6 @@ class KnowledgeService:
|
||||
require_workspace_uuid(context)
|
||||
# In new architecture, we delegate entirely to RAGManager which uses plugins.
|
||||
# Legacy internal KB creation is removed.
|
||||
limitation = (
|
||||
getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {})
|
||||
)
|
||||
max_knowledge_bases = limitation.get('max_knowledge_bases', -1)
|
||||
if max_knowledge_bases >= 0:
|
||||
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
|
||||
if len(knowledge_bases) >= max_knowledge_bases:
|
||||
raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached')
|
||||
|
||||
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
|
||||
if not knowledge_engine_plugin_id:
|
||||
|
||||
@@ -186,14 +186,9 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
# At the final key
|
||||
if key in current:
|
||||
if isinstance(current[key], list):
|
||||
# Convert comma-separated values while preserving the
|
||||
# element type declared by a non-empty config default.
|
||||
items = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||
if current[key]:
|
||||
exemplar = current[key][0]
|
||||
current[key] = [convert_value(item, exemplar) for item in items]
|
||||
else:
|
||||
current[key] = items
|
||||
# Convert comma-separated string to list
|
||||
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||
elif isinstance(current[key], dict):
|
||||
# Skip dict types
|
||||
pass
|
||||
|
||||
@@ -892,4 +892,4 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.logger.info('Telegram adapter stopped')
|
||||
self.msg_stream_id.clear()
|
||||
self._form_action_titles.clear()
|
||||
return True
|
||||
return True
|
||||
@@ -529,7 +529,7 @@ class ModelManager:
|
||||
model['uuid']: model
|
||||
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
|
||||
}
|
||||
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models(context)}
|
||||
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models()}
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
@@ -602,7 +602,6 @@ class ModelManager:
|
||||
existing = existing_rerank_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
await self.ap.rerank_models_service.create_rerank_model(
|
||||
context,
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
@@ -623,9 +622,7 @@ class ModelManager:
|
||||
existing.get('name') != desired['name']
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.rerank_models_service.update_rerank_model(
|
||||
context, space_model.uuid, dict(desired)
|
||||
)
|
||||
await self.ap.rerank_models_service.update_rerank_model(space_model.uuid, dict(desired))
|
||||
updated += 1
|
||||
|
||||
if created or updated:
|
||||
|
||||
@@ -499,14 +499,14 @@ class WorkspaceCollaborationService:
|
||||
return await self._run(operation, session=session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _invitation_lock(self, lock_key: str):
|
||||
"""Serialize one token within workspace scope while retaining only active lock entries."""
|
||||
async def _invitation_lock(self, token_digest: str):
|
||||
"""Serialize one token while retaining only active lock entries."""
|
||||
|
||||
async with self._invitation_locks_guard:
|
||||
entry = self._invitation_locks.get(lock_key)
|
||||
entry = self._invitation_locks.get(token_digest)
|
||||
if entry is None:
|
||||
entry = _InvitationLockEntry(lock=asyncio.Lock())
|
||||
self._invitation_locks[lock_key] = entry
|
||||
self._invitation_locks[token_digest] = entry
|
||||
entry.users += 1
|
||||
|
||||
await entry.lock.acquire()
|
||||
@@ -517,7 +517,7 @@ class WorkspaceCollaborationService:
|
||||
async with self._invitation_locks_guard:
|
||||
entry.users -= 1
|
||||
if entry.users == 0:
|
||||
self._invitation_locks.pop(lock_key, None)
|
||||
self._invitation_locks.pop(token_digest, None)
|
||||
|
||||
async def revoke_invitation(
|
||||
self,
|
||||
|
||||
@@ -105,7 +105,6 @@ system:
|
||||
max_bots: -1
|
||||
max_pipelines: -1
|
||||
max_extensions: -1
|
||||
max_knowledge_bases: -1
|
||||
# When set to a non-empty string, every pipeline is forced to use this
|
||||
# Box sandbox-scope template regardless of its own configuration, and
|
||||
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
|
||||
|
||||
@@ -34,7 +34,6 @@ class _Rows:
|
||||
def _app():
|
||||
return SimpleNamespace(
|
||||
logger=Mock(),
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
rag_mgr=SimpleNamespace(
|
||||
get_all_knowledge_base_details=AsyncMock(return_value=[]),
|
||||
get_knowledge_base_details=AsyncMock(return_value=None),
|
||||
@@ -120,21 +119,6 @@ async def test_create_validates_schema_and_binds_context():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_enforces_workspace_knowledge_base_limit():
|
||||
app = _app()
|
||||
app.instance_config.data = {'system': {'limitation': {'max_knowledge_bases': 2}}}
|
||||
app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb-a'}, {'uuid': 'kb-b'}]
|
||||
service = KnowledgeService(app)
|
||||
|
||||
with pytest.raises(ValueError, match=r'Maximum number of knowledge bases \(2\) reached'):
|
||||
await service.create_knowledge_base(
|
||||
CONTEXT,
|
||||
{'knowledge_engine_plugin_id': 'author/engine'},
|
||||
)
|
||||
app.rag_mgr.create_knowledge_base.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rejects_guessed_uuid_and_scopes_reload():
|
||||
app = _app()
|
||||
|
||||
@@ -862,12 +862,12 @@ class TestModelProviderServiceScanProviderModels:
|
||||
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
|
||||
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(return_value=[{'name': 'Qwen3-Reranker-8B'}])
|
||||
|
||||
result = await ModelProviderService(ap).scan_provider_models(
|
||||
WORKSPACE_UUID, 'rerank-scan-uuid', model_type='rerank'
|
||||
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(
|
||||
return_value=[{'name': 'Qwen3-Reranker-8B'}]
|
||||
)
|
||||
|
||||
result = await ModelProviderService(ap).scan_provider_models('rerank-scan-uuid', model_type='rerank')
|
||||
|
||||
assert result['models'][0]['type'] == 'rerank'
|
||||
assert result['models'][0]['already_added'] is True
|
||||
|
||||
|
||||
@@ -152,19 +152,6 @@ class TestApplyEnvOverridesToConfig:
|
||||
|
||||
assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram']
|
||||
|
||||
def test_override_integer_list_preserves_item_type(self):
|
||||
"""Comma-separated overrides inherit the existing list item type."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
cfg = {'vdb': {'pgvector': {'allowed_dimensions': [384, 512]}}}
|
||||
env = {'VDB__PGVECTOR__ALLOWED_DIMENSIONS': '384,512,768'}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['vdb']['pgvector']['allowed_dimensions'] == [384, 512, 768]
|
||||
assert all(isinstance(item, int) for item in result['vdb']['pgvector']['allowed_dimensions'])
|
||||
|
||||
def test_override_list_value_empty_items(self):
|
||||
"""Test that empty items in comma-separated list are filtered."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
@@ -114,10 +114,9 @@ async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_mod
|
||||
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
|
||||
|
||||
model_mgr = ModelManager(app)
|
||||
await model_mgr.sync_new_models_from_space(TEST_EXECUTION_CONTEXT)
|
||||
await model_mgr.sync_new_models_from_space()
|
||||
|
||||
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
|
||||
TEST_EXECUTION_CONTEXT,
|
||||
{
|
||||
'uuid': 'rerank-model-uuid',
|
||||
'name': 'Qwen3-Reranker-8B',
|
||||
@@ -1110,4 +1109,4 @@ def test_provider_not_found_error_str():
|
||||
error = provider_errors.ProviderNotFoundError('test-provider')
|
||||
|
||||
assert str(error) == 'Provider test-provider not found'
|
||||
assert error.provider_name == 'test-provider'
|
||||
assert error.provider_name == 'test-provider'
|
||||
Reference in New Issue
Block a user