feat: 装配切换——main/服务构造线/conftest 迁至 Registry(含默认租户过渡兜底)

- main.py: 删全局 engine/async_session;lifespan 装配 TenantRegistry
  (seed_profiles → 仓库根 tenants/,路径锚定),预热默认租户 jiancai 挂
  app.state.default_runtime,逐 active 租户(库文件已存在)跑
  sweep_stale_pending,退出 dispose_all。app.state.registry 预置即沿用
  (测试注入缝)
- routes/runtime.py(过渡,Task 7 删): get_runtime——state.tenant 优先,
  缺省落 default_runtime;chat.py/chat_rest.py 全部改走它,消除
  main↔routes 循环依赖
- ConversationManager 构造线: 新增 runtime 可选参(持 session_factory/
  compiled_wall/config 引用;prompts 仍模块级,Task 6;业务接线 Task 9),
  fire_pending_pushes 随任务传 session_factory(后台写回派发租户的库)
- lead_push_service: 去 app.main import;写库入口显式收 session_factory,
  模块级 async_session=None 仅作测试注入兜底
- conftest: client 夹具 = 每测试独立 Registry(tmp data_dir)+ 真实
  lifespan 在 TestClient 循环内预热;删 _dispose_main_engine;
  async_db 保持 NullPool(跨 asyncio.run 循环安全,刻意不换 Registry 引擎)
- 测试仅动夹具/桩签名(断言零改动): wiring 桩补 session_factory 形参;
  history-404 用例改经真实 WS 路由造 widget 会话(引擎常驻连接绑定
  TestClient 循环,测试线程不得直开 session_factory)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 19:15:46 +08:00
parent 0c296ca1ba
commit d041077bb6
11 changed files with 198 additions and 137 deletions
+35 -16
View File
@@ -1,30 +1,49 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.engine.config import settings, ensure_dirs, STATIC_DIR
from app.engine.models import ensure_schema
from app.engine.config import STATIC_DIR
from app.platform.registry import TenantRegistry
# 后台推送任务的独立连接写结果可能撞上 SMTP 窗口内持有的写锁,默认 5s
# 忙等上限低于 SMTP 最坏时长,抬到 30s;等待发生在 aiosqlite 工作线程,不阻塞事件循环
engine = create_async_engine(settings.DATABASE_URL, echo=settings.APP_DEBUG,
connect_args={"timeout": 30})
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# 路径锚定(不随 CWD 漂移):seed_profiles 与 tenants 都在仓库根
BASE_DIR = Path(__file__).resolve().parents[1]
SEED_DIR = BASE_DIR / "seed_profiles"
TENANTS_DIR = BASE_DIR / "tenants" # .gitignore 已忽略 tenants/*.db
# 过渡兜底(Task 5b→7,中间件上线后随 routes/runtime.py 一并删除):
# Host 中间件落地前请求 state 里没有租户,一律落默认租户 jiancai
DEFAULT_TENANT_HOST = "chat2.agent.hnxinyouhe.com"
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_dirs()
await ensure_schema(engine)
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed
# 函数内导入: lead_push_service 顶部 from app.main import async_session,
# 顶层互相导入会循环
# 测试注入缝:conftest 预置过 app.state.registry(临时 data_dir 的独立
# Registry)则沿用,不再加载仓库 seed——测试租户库与生产互不污染
registry = getattr(app.state, "registry", None)
if registry is None:
registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=TENANTS_DIR)
app.state.registry = registry
# 预热默认租户(过渡兜底读 app.state.default_runtime)。engine 建在本
# lifespan 的事件循环里——连接与 loop 绑定,别处另起 asyncio.run 复用
# 同一 engine 会跨 loop 死锁(测试直连造数走 conftest.async_db)
app.state.default_runtime = await registry.resolve(DEFAULT_TENANT_HOST)
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed。逐租户、
# 用该租户自己的 session_factory;db 文件不存在的租户跳过——全新库
# 无遗留行,保持 engine 懒建纪律(预热的默认租户文件已建,照常扫)。
from app.engine.services.lead_push_service import sweep_stale_pending
await sweep_stale_pending()
yield
for rt in registry.runtimes():
if rt.config.status != "active" or not rt.db_path.exists():
continue
ready = await registry.ensure_ready(rt)
await sweep_stale_pending(session_factory=ready.session_factory)
try:
yield
finally:
await registry.dispose_all() # Windows 文件锁: 不关则库文件删不掉
app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)