Files
ai-kefu-admin/app/main.py
T

196 lines
9.6 KiB
Python
Raw Normal View History

from contextlib import asynccontextmanager
from pathlib import Path
import logging
import os
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.config import STATIC_DIR, settings
from app.platform.db import (
Tenant,
bootstrap_superuser,
ensure_platform_schema,
make_platform_engine,
)
from app.platform.host_middleware import TenantHostMiddleware
from app.platform.registry import TenantRegistry
from app.platform.tenant_source import DbTenantSource
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
PLATFORM_DB = BASE_DIR / "platform.db" # .gitignore 已忽略 platform.db*
@asynccontextmanager
async def lifespan(app: FastAPI):
# registry 装配(Task 11 生产切换):测试注入缝优先——conftest 预置过
# app.state.registry 则沿用(既有测试全走预置,不受 env 默认切换影响),
# 预置路径完全不读 TENANT_SOURCE。未预置才按 env 装配:db(默认,生产
# 形态)DbTenantSource(platform.db);file 回退旧文件源(冷备/本地调试)。
# 非法值在创建任何 engine 之前显性炸——拼错名静默跑错数据源,与"空源
# 必须炸"同一纪律。
registry = getattr(app.state, "registry", None)
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})")
# 平台库测试缝(Phase 2 Task 1,同 registry 缝模式):预置过
# app.state.platform_engine(临时库)则沿用——不预置则每个 TestClient
# 测试都会在仓库根建出真实 platform.db。schema+bootstrap 无条件跑
# (两者幂等):预置引擎同样被建到可用,Task 2 起认证测试直接可用。
# 装配次序(Task 11 起)engine 先于 registry:db 源 registry 构造即需
# 持有 platform_engine。
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
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
try:
await ensure_platform_schema(platform_engine)
# 默认凭据警示(终审 I-4):ADMIN_PASSWORD 仍是出厂默认时启动日志
# 醒目提示。比较对象取 Settings 字段定义的默认值(非硬编码第二份
# "admin123"——将来改默认值,警示判定自动跟随,不双源漂移)。
if settings.ADMIN_PASSWORD == type(
settings).model_fields["ADMIN_PASSWORD"].default:
logger.warning(
"!!! 安全警示: 超管账号 %s 正在使用默认密码 —— 生产环境必须"
"设置 ADMIN_PASSWORD 环境变量,且首登后立即改密 !!!",
settings.ADMIN_USERNAME)
await bootstrap_superuser(
platform_engine,
username=settings.ADMIN_USERNAME,
password=settings.ADMIN_PASSWORD)
# 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 导入出厂租户")
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed。逐租户、
# 用该租户自己的 session_factory;db 文件不存在的租户跳过——全新库
# 无遗留行,保持 engine 懒建纪律(租户 engine 由 Host 中间件按请求
# 懒建,启动期只有已有库文件的租户会被扫描)。
# 清扫失败只记日志继续:一个租户的坏库不炸整个平台启动(其余租户
# 照常服务),该租户下轮重启还会重扫。
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 文件锁: 不关则库文件删不掉)。platform_engine 含预置的
# 测试引擎——dispose 后 engine 对象仍可重连,测试侧重置引用即可。
await registry.dispose_all()
await platform_engine.dispose()
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
from app.engine.routes.public import router as public_router
from app.platform.routes.auth_routes import router as platform_auth_router
from app.platform.routes.config_routes import (
router as platform_config_router,
settings_router as platform_settings_router,
)
from app.platform.routes.data_routes import router as platform_data_router
from app.platform.routes.stats_routes import router as platform_stats_router
from app.platform.routes.tenant_routes import router as platform_tenant_router
from app.platform.routes.upload_routes import router as platform_upload_router
app.include_router(chat_router)
app.include_router(chat_rest_router)
app.include_router(public_router)
# 租户管理 API(Task 5):创建向导/站点/域名/停用——同样只依赖路由层认证
# (require_super / require_tenant),Host 豁免面归 Task 3 的前缀规则覆盖。
app.include_router(platform_tenant_router)
# 配置 API(Task 6):分组校验管线/整份版本/回滚/试金石/重置出厂(鉴权
# require_tenant,租户全开)+ 平台设置(require_super)——/api/platform/
# 前缀的 Host 豁免由 Task 3 的中间件规则统一覆盖。
app.include_router(platform_config_router)
app.include_router(platform_settings_router)
# 头像上传(Task 7):multipart 魔数校验 + uuid 重命名 + 配置版本管线
# (require_tenant);/api/public/avatar 的三级解析链消费上传产物。
app.include_router(platform_upload_router)
# 数据视图(Task 8): 会话列表(筛选/分页)/详情/删除 + 推送日志——跑在租户
# runtime 的 session_factory 上(slug → runtimes() 匹配 + ensure_ready 懒建),
# 鉴权 _tenant_guard;查询语义自 jc admin.py 移植。
app.include_router(platform_data_router)
# 统计 API(Task 9): 卡片/漏斗/趋势/分布/推送健康——聚合在 app/platform/
# stats.py,逐库查询应用层合并(禁 ATTACH,spec §7.6);合并端点 require_super
# (?tenant= 单租户下钻),单租户端点 _tenant_guard。
app.include_router(platform_stats_router)
# 平台认证路由(Task 2):本任务即挂——已知租户 Host(如 jiancai)下
# /api/platform/auth/* 经 Host 中间件直达,TDD 循环闭环。Task 3 将为
# /api/platform/ 前缀加中间件豁免(平台后台不依赖租户域名解析);
# 豁免≠放行——认证由路由依赖(get_current_user 系)守护。
app.include_router(platform_auth_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
# 租户路由中间件(Task 7): 最后 add = 最外层——租户解析先于一切用户中间件。
# 纯 ASGI 类(http+websocket 双覆盖),registry 经 scope["app"] 延迟取,
# 因此可在 lifespan 挂 Registry 之前于模块级注册。
app.add_middleware(TenantHostMiddleware)
@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"}