20dddc151e
- main.lifespan: 预热+清扫挪进 try,finally dispose_all 覆盖启动半途 失败(幂等于更少引擎,堵 engine 泄漏);清扫循环体 per-tenant try/except + logger.exception 后继续——一个租户坏库不炸平台启动, resolve/load_from_dir 保持 fail-fast - routes/runtime.py: RuntimeError 文案补 registry_reload dispose 旧 runtime 成因;TenantRuntime 注解挪 TYPE_CHECKING(engine 包运行期 零 import platform) - conftest client: teardown 置空 app.state.registry(关残留窗); rmtree 先正常删,失败 warnings.warn 留痕后再 ignore_errors 兜底 Co-Authored-By: Claude Code <noreply@anthropic.com>
92 lines
3.8 KiB
Python
92 lines
3.8 KiB
Python
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
import logging
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from app.engine.config import STATIC_DIR
|
|
from app.platform.registry import TenantRegistry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 路径锚定(不随 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):
|
|
# 测试注入缝: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
|
|
|
|
try:
|
|
# 预热默认租户(过渡兜底读 app.state.default_runtime)。engine 建在
|
|
# 本 lifespan 的事件循环里——连接与 loop 绑定,别处另起 asyncio.run
|
|
# 复用同一 engine 会跨 loop 死锁(测试直连造数走 conftest.async_db)。
|
|
# 预热/加载保持 fail-fast:默认租户起不来 = 过渡期全站无租户。
|
|
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
|
|
for rt in registry.runtimes():
|
|
if rt.config.status != "active" or not rt.db_path.exists():
|
|
continue
|
|
try:
|
|
ready = await registry.ensure_ready(rt)
|
|
await sweep_stale_pending(session_factory=ready.session_factory)
|
|
except Exception:
|
|
logger.exception(
|
|
"启动清扫失败,跳过该租户(slug=%s, db=%s)",
|
|
rt.config.slug, rt.db_path)
|
|
|
|
yield
|
|
finally:
|
|
# 幂等于"更少的引擎":启动半途失败也把已建好的 engine 关掉
|
|
# (Windows 文件锁: 不关则库文件删不掉)
|
|
await registry.dispose_all()
|
|
|
|
|
|
app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)
|
|
|
|
from app.engine.routes.chat import router as chat_router
|
|
from app.engine.routes.chat_rest import router as chat_rest_router
|
|
app.include_router(chat_router)
|
|
app.include_router(chat_rest_router)
|
|
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
|
|
@app.middleware("http")
|
|
async def static_no_cache(request, call_next):
|
|
"""静态资源协商缓存(2026-09-30 线上事件): 无 Cache-Control 时浏览器对
|
|
widget.js 走启发式缓存,修复部署后用户端仍跑旧脚本。no-cache=每次用
|
|
etag 轻量协商,有更新即时生效,未变则 304 几乎零开销。"""
|
|
resp = await call_next(request)
|
|
if request.url.path.startswith("/static/"):
|
|
resp.headers["Cache-Control"] = "no-cache"
|
|
return resp
|
|
|
|
|
|
@app.get("/")
|
|
async def index():
|
|
from fastapi.responses import FileResponse
|
|
return FileResponse(str(STATIC_DIR / "chat.html"))
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|