2026-10-12 16:25:32 +08:00
|
|
|
from contextlib import asynccontextmanager
|
2026-10-12 19:15:46 +08:00
|
|
|
from pathlib import Path
|
2026-10-12 19:37:51 +08:00
|
|
|
import logging
|
2026-10-13 12:05:42 +08:00
|
|
|
import os
|
2026-10-12 16:25:32 +08:00
|
|
|
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
2026-10-13 12:05:42 +08:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-10-12 16:25:32 +08:00
|
|
|
|
2026-10-13 07:57:15 +08:00
|
|
|
from app.engine.config import STATIC_DIR, settings
|
|
|
|
|
from app.platform.db import (
|
2026-10-13 12:05:42 +08:00
|
|
|
Tenant,
|
2026-10-13 07:57:15 +08:00
|
|
|
bootstrap_superuser,
|
|
|
|
|
ensure_platform_schema,
|
|
|
|
|
make_platform_engine,
|
|
|
|
|
)
|
2026-10-12 20:30:30 +08:00
|
|
|
from app.platform.host_middleware import TenantHostMiddleware
|
2026-10-12 19:15:46 +08:00
|
|
|
from app.platform.registry import TenantRegistry
|
2026-10-13 12:05:42 +08:00
|
|
|
from app.platform.tenant_source import DbTenantSource
|
2026-10-12 16:25:32 +08:00
|
|
|
|
2026-10-12 19:37:51 +08:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-10-12 19:15:46 +08:00
|
|
|
# 路径锚定(不随 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
|
2026-10-13 07:57:15 +08:00
|
|
|
PLATFORM_DB = BASE_DIR / "platform.db" # .gitignore 已忽略 platform.db*
|
2026-10-12 16:25:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app: FastAPI):
|
2026-10-13 12:05:42 +08:00
|
|
|
# registry 装配(Task 11 生产切换):测试注入缝优先——conftest 预置过
|
|
|
|
|
# app.state.registry 则沿用(既有测试全走预置,不受 env 默认切换影响),
|
|
|
|
|
# 预置路径完全不读 TENANT_SOURCE。未预置才按 env 装配:db(默认,生产
|
|
|
|
|
# 形态)DbTenantSource(platform.db);file 回退旧文件源(冷备/本地调试)。
|
|
|
|
|
# 非法值在创建任何 engine 之前显性炸——拼错名静默跑错数据源,与"空源
|
|
|
|
|
# 必须炸"同一纪律。
|
2026-10-12 19:15:46 +08:00
|
|
|
registry = getattr(app.state, "registry", None)
|
2026-10-13 12:05:42 +08:00
|
|
|
assemble_from_env = registry is None
|
|
|
|
|
tenant_source = os.environ.get("TENANT_SOURCE", "db").strip().lower()
|
|
|
|
|
if assemble_from_env and tenant_source not in ("db", "file"):
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"TENANT_SOURCE 只允许 db|file(实际 {tenant_source!r})")
|
2026-10-12 19:15:46 +08:00
|
|
|
|
2026-10-13 07:57:15 +08:00
|
|
|
# 平台库测试缝(Phase 2 Task 1,同 registry 缝模式):预置过
|
|
|
|
|
# app.state.platform_engine(临时库)则沿用——不预置则每个 TestClient
|
|
|
|
|
# 测试都会在仓库根建出真实 platform.db。schema+bootstrap 无条件跑
|
|
|
|
|
# (两者幂等):预置引擎同样被建到可用,Task 2 起认证测试直接可用。
|
2026-10-13 12:05:42 +08:00
|
|
|
# 装配次序(Task 11 起)engine 先于 registry:db 源 registry 构造即需
|
|
|
|
|
# 持有 platform_engine。
|
2026-10-13 07:57:15 +08:00
|
|
|
platform_engine = getattr(app.state, "platform_engine", None)
|
|
|
|
|
if platform_engine is None:
|
|
|
|
|
platform_engine = make_platform_engine(PLATFORM_DB)
|
|
|
|
|
app.state.platform_engine = platform_engine
|
|
|
|
|
|
2026-10-13 12:05:42 +08:00
|
|
|
if assemble_from_env:
|
|
|
|
|
if tenant_source == "db":
|
|
|
|
|
registry = TenantRegistry(
|
|
|
|
|
DbTenantSource(platform_engine), data_dir=TENANTS_DIR)
|
|
|
|
|
else:
|
|
|
|
|
registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=TENANTS_DIR)
|
|
|
|
|
app.state.registry = registry
|
|
|
|
|
|
2026-10-12 19:15:46 +08:00
|
|
|
try:
|
2026-10-13 07:57:15 +08:00
|
|
|
await ensure_platform_schema(platform_engine)
|
|
|
|
|
await bootstrap_superuser(
|
|
|
|
|
platform_engine,
|
|
|
|
|
username=settings.ADMIN_USERNAME,
|
|
|
|
|
password=settings.ADMIN_PASSWORD)
|
|
|
|
|
|
2026-10-13 12:05:42 +08:00
|
|
|
# db 源初始加载(Task 11):schema 就绪后现势加载(空库无表,先建
|
|
|
|
|
# schema 才能查)。空平台(platform.db 无租户——init 后未导入)不炸:
|
|
|
|
|
# 保持空注册表启动(域名全 404 直到 bootstrap 导入;下方清扫循环对
|
|
|
|
|
# runtimes() 空元组安全)。探空用显式预检而非捕 ValueError——
|
|
|
|
|
# load_all 对坏数据(缺版本/坏 JSON/缺配置组)同样抛 ValueError,
|
|
|
|
|
# 一揽子吞会把数据损坏静默成"空平台",必须区分。
|
|
|
|
|
if assemble_from_env and tenant_source == "db":
|
|
|
|
|
async with AsyncSession(platform_engine) as session:
|
|
|
|
|
has_tenant = (await session.execute(
|
|
|
|
|
select(Tenant.id).limit(1))).first() is not None
|
|
|
|
|
if has_tenant:
|
|
|
|
|
await registry.load_all()
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"platform.db 无租户——空注册表启动(域名全 404);"
|
|
|
|
|
"跑 scripts/bootstrap_platform.py 导入出厂租户")
|
|
|
|
|
|
2026-10-12 19:37:51 +08:00
|
|
|
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed。逐租户、
|
|
|
|
|
# 用该租户自己的 session_factory;db 文件不存在的租户跳过——全新库
|
2026-10-12 20:30:30 +08:00
|
|
|
# 无遗留行,保持 engine 懒建纪律(租户 engine 由 Host 中间件按请求
|
|
|
|
|
# 懒建,启动期只有已有库文件的租户会被扫描)。
|
2026-10-12 19:37:51 +08:00
|
|
|
# 清扫失败只记日志继续:一个租户的坏库不炸整个平台启动(其余租户
|
|
|
|
|
# 照常服务),该租户下轮重启还会重扫。
|
|
|
|
|
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)
|
|
|
|
|
|
2026-10-12 19:15:46 +08:00
|
|
|
yield
|
|
|
|
|
finally:
|
2026-10-12 19:37:51 +08:00
|
|
|
# 幂等于"更少的引擎":启动半途失败也把已建好的 engine 关掉
|
2026-10-13 07:57:15 +08:00
|
|
|
# (Windows 文件锁: 不关则库文件删不掉)。platform_engine 含预置的
|
|
|
|
|
# 测试引擎——dispose 后 engine 对象仍可重连,测试侧重置引用即可。
|
2026-10-12 19:37:51 +08:00
|
|
|
await registry.dispose_all()
|
2026-10-13 07:57:15 +08:00
|
|
|
await platform_engine.dispose()
|
2026-10-12 16:25:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-12 22:26:15 +08:00
|
|
|
from app.engine.routes.public import router as public_router
|
2026-10-13 08:18:15 +08:00
|
|
|
from app.platform.routes.auth_routes import router as platform_auth_router
|
2026-10-13 10:07:40 +08:00
|
|
|
from app.platform.routes.config_routes import (
|
|
|
|
|
router as platform_config_router,
|
|
|
|
|
settings_router as platform_settings_router,
|
|
|
|
|
)
|
2026-10-13 11:00:39 +08:00
|
|
|
from app.platform.routes.data_routes import router as platform_data_router
|
2026-10-13 11:24:30 +08:00
|
|
|
from app.platform.routes.stats_routes import router as platform_stats_router
|
2026-10-13 09:20:28 +08:00
|
|
|
from app.platform.routes.tenant_routes import router as platform_tenant_router
|
2026-10-13 10:35:41 +08:00
|
|
|
from app.platform.routes.upload_routes import router as platform_upload_router
|
2026-10-12 16:25:32 +08:00
|
|
|
app.include_router(chat_router)
|
|
|
|
|
app.include_router(chat_rest_router)
|
2026-10-12 22:26:15 +08:00
|
|
|
app.include_router(public_router)
|
2026-10-13 09:20:28 +08:00
|
|
|
# 租户管理 API(Task 5):创建向导/站点/域名/停用——同样只依赖路由层认证
|
|
|
|
|
# (require_super / require_tenant),Host 豁免面归 Task 3 的前缀规则覆盖。
|
|
|
|
|
app.include_router(platform_tenant_router)
|
2026-10-13 10:07:40 +08:00
|
|
|
# 配置 API(Task 6):分组校验管线/整份版本/回滚/试金石/重置出厂(鉴权
|
|
|
|
|
# require_tenant,租户全开)+ 平台设置(require_super)——/api/platform/
|
|
|
|
|
# 前缀的 Host 豁免由 Task 3 的中间件规则统一覆盖。
|
|
|
|
|
app.include_router(platform_config_router)
|
|
|
|
|
app.include_router(platform_settings_router)
|
2026-10-13 10:35:41 +08:00
|
|
|
# 头像上传(Task 7):multipart 魔数校验 + uuid 重命名 + 配置版本管线
|
|
|
|
|
# (require_tenant);/api/public/avatar 的三级解析链消费上传产物。
|
|
|
|
|
app.include_router(platform_upload_router)
|
2026-10-13 11:00:39 +08:00
|
|
|
# 数据视图(Task 8): 会话列表(筛选/分页)/详情/删除 + 推送日志——跑在租户
|
|
|
|
|
# runtime 的 session_factory 上(slug → runtimes() 匹配 + ensure_ready 懒建),
|
|
|
|
|
# 鉴权 _tenant_guard;查询语义自 jc admin.py 移植。
|
|
|
|
|
app.include_router(platform_data_router)
|
2026-10-13 11:24:30 +08:00
|
|
|
# 统计 API(Task 9): 卡片/漏斗/趋势/分布/推送健康——聚合在 app/platform/
|
|
|
|
|
# stats.py,逐库查询应用层合并(禁 ATTACH,spec §7.6);合并端点 require_super
|
|
|
|
|
# (?tenant= 单租户下钻),单租户端点 _tenant_guard。
|
|
|
|
|
app.include_router(platform_stats_router)
|
2026-10-13 08:18:15 +08:00
|
|
|
# 平台认证路由(Task 2):本任务即挂——已知租户 Host(如 jiancai)下
|
|
|
|
|
# /api/platform/auth/* 经 Host 中间件直达,TDD 循环闭环。Task 3 将为
|
|
|
|
|
# /api/platform/ 前缀加中间件豁免(平台后台不依赖租户域名解析);
|
|
|
|
|
# 豁免≠放行——认证由路由依赖(get_current_user 系)守护。
|
|
|
|
|
app.include_router(platform_auth_router)
|
2026-10-12 16:25:32 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 20:30:30 +08:00
|
|
|
# 租户路由中间件(Task 7): 最后 add = 最外层——租户解析先于一切用户中间件。
|
|
|
|
|
# 纯 ASGI 类(http+websocket 双覆盖),registry 经 scope["app"] 延迟取,
|
|
|
|
|
# 因此可在 lifespan 挂 Registry 之前于模块级注册。
|
|
|
|
|
app.add_middleware(TenantHostMiddleware)
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 16:25:32 +08:00
|
|
|
@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"}
|