f938118fee
LoginLimiter._fails 与 rate_limit._buckets 只增不删——懒清理只覆盖被 回访的键,扫号换 IP/设备轮换产生的键永不被回访 → 进程长跑缓慢累积。 两处各加 sweep()(全表清「全部时间戳均已出窗」的键,窗沿 <= 与既有 保留判据 t > now - W 同沿),main.lifespan 起周期任务(300s loop) 启动创建/关停取消(cancel 后 await 落地,不留 pending task)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
342 lines
17 KiB
Python
342 lines
17 KiB
Python
from contextlib import asynccontextmanager, suppress
|
|
from pathlib import Path
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import select, text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
from starlette.responses import FileResponse, PlainTextResponse
|
|
from starlette.types import Receive, Scope, Send
|
|
|
|
from app.engine.config import STATIC_DIR, settings
|
|
from app.engine.services import rate_limit
|
|
from app.platform.auth import login_limiter
|
|
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 import settings_store
|
|
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
|
|
# 平台库落 data/ 子目录(P4T1 审查修复):data/ 是 compose 的 bind mount
|
|
# ——容器/镜像重建不丢租户注册表(租户库本就在挂载的 tenants/ 内);
|
|
# .gitignore 的 data/ 规则覆盖其运行产物。
|
|
PLATFORM_DB = BASE_DIR / "data" / "platform.db"
|
|
|
|
|
|
# ---- admin 前端生产服务(Phase 4 Task 1)---------------------------------------
|
|
|
|
ADMIN_NOT_BUILT_MSG = (
|
|
"admin 前端未构建(admin/dist 缺失)。生产镜像内由两阶段 Dockerfile 的"
|
|
" node 阶段产出;开发模式请用 cd admin && pnpm dev(Vite dev server,"
|
|
" API 经代理指向本服务)。")
|
|
|
|
|
|
def resolve_admin_dist() -> Path:
|
|
"""admin 构建产物目录:env ADMIN_DIST 覆盖(测试注入/非常规部署),
|
|
默认 <repo>/admin/dist(vite build 的 outDir)。请求期现势解析——
|
|
构建完成后无需重启进程即可生效,也让测试能逐用例换目录。"""
|
|
env = os.environ.get("ADMIN_DIST")
|
|
return Path(env) if env else BASE_DIR / "admin" / "dist"
|
|
|
|
|
|
class AdminSPA:
|
|
"""/admin 的 SPA 静态服务(纯 ASGI,整体作为 Mount 的 app)。
|
|
|
|
- dist 存在(以 index.html 为准):等价 StaticFiles(html=True)——
|
|
目录请求落 index.html、assets 按文件服务;
|
|
- dist 缺失:503 纯文本说明(等价"不 mount + 说明页"的可观测行为,
|
|
且免去模块导入期的双分支——导入时序决定挂载会让测试与"构建后
|
|
不重启生效"都不可行);
|
|
- SPA fallback:Mount 内 404(hash 路由下是加固——直连/刷新深链不
|
|
白屏)回 index.html 200;/api 子前缀除外(未来 admin 侧 API 的路由
|
|
404 必须按默认 JSON 暴露,不被 index.html 吞)。fallback 收在
|
|
Mount 内部而非全局 ExceptionHandler:不吃全局 404 语义(其余路径
|
|
的默认 JSON 404 原样保留,零越界面),子路径判 /api 即判
|
|
/admin/api(见 __call__ 内的 root_path 归一)。
|
|
|
|
StaticFiles 按目录缓存(ADMIN_DIST 切换/dist 重建时换新实例);
|
|
构造只查目录存在,并发首请求双建幂等,末次赋值胜出。
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._cached_dir: Path | None = None
|
|
self._static: StaticFiles | None = None
|
|
|
|
def _static_for(self, dist: Path) -> StaticFiles:
|
|
if self._static is None or self._cached_dir != dist:
|
|
self._static = StaticFiles(directory=str(dist), html=True)
|
|
self._cached_dir = dist
|
|
return self._static
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive,
|
|
send: Send) -> None:
|
|
dist = resolve_admin_dist()
|
|
if not (dist / "index.html").is_file():
|
|
await PlainTextResponse(
|
|
ADMIN_NOT_BUILT_MSG, status_code=503)(scope, receive, send)
|
|
return
|
|
try:
|
|
await self._static_for(dist)(scope, receive, send)
|
|
except StarletteHTTPException as exc:
|
|
# 余段子路径 = path 剥去 root_path 挂载段:Starlette 1.8 的
|
|
# Mount 不改写 scope["path"](仅追加 root_path),老版本语义则
|
|
# 直接给剥好的 path——两种形态下都归一出 "/xxx" 再判 /api。
|
|
full_path = scope.get("path") or ""
|
|
root_path = scope.get("root_path") or ""
|
|
sub = (full_path[len(root_path):]
|
|
if root_path and full_path.startswith(root_path)
|
|
else full_path)
|
|
if (exc.status_code != 404
|
|
or sub == "/api" or sub.startswith("/api/")):
|
|
raise
|
|
await FileResponse(str(dist / "index.html"))(scope, receive, send)
|
|
|
|
|
|
@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(临时库)则沿用——不预置则会在 <repo>/data/
|
|
# 下建出真实 platform.db(生产;测试必须预置或锚到 tmp)。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:
|
|
# sqlite 不建父目录——冷机 data/ 可能尚不存在(全新检出/新容器),
|
|
# 连接前兜底建目录(registry._ensure_ready 同纪律)
|
|
PLATFORM_DB.parent.mkdir(parents=True, exist_ok=True)
|
|
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
|
|
|
|
# 周期清扫任务句柄:启动成功(yield 前)才创建,finally 里按非 None 取消
|
|
# ——启动半途失败时跳过,不给清理段引入未定义名。
|
|
_sweep_task: asyncio.Task | None = None
|
|
|
|
try:
|
|
await ensure_platform_schema(platform_engine)
|
|
# 平台设置快照(Phase 4 Task 5A):schema 就绪后全表读入内存——
|
|
# resolver 平台默认回退链(config_views)的数据源。PUT settings
|
|
# 后经 settings_store.refresh 热刷新,无需重启。
|
|
await settings_store.load_snapshot(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)
|
|
|
|
# 铁律(2026-10-19)进程内面: 登录/聊天限流器过期键周期清扫(spec
|
|
# §7——两处内存态字典只增不删,不被回访的键永生;语义见各 sweep
|
|
# 注记)。300s 量级足够:清速远高于键积压速度,首步 sleep 不占启动。
|
|
async def _limiter_sweep_loop() -> None:
|
|
while True:
|
|
await asyncio.sleep(300)
|
|
login_limiter.sweep()
|
|
rate_limit.sweep()
|
|
|
|
_sweep_task = asyncio.create_task(_limiter_sweep_loop())
|
|
|
|
yield
|
|
finally:
|
|
# 幂等于"更少的引擎":启动半途失败也把已建好的 engine 关掉
|
|
# (Windows 文件锁: 不关则库文件删不掉)。platform_engine 含预置的
|
|
# 测试引擎——dispose 后 engine 对象仍可重连,测试侧重置引用即可。
|
|
if _sweep_task is not None:
|
|
_sweep_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await _sweep_task # 等 cancel 落地:不留 pending task 给循环收尾
|
|
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")
|
|
# admin SPA(Phase 4 Task 1): 与 API 路由前缀互斥,挂载次序无冲突;
|
|
# Host 中间件已豁免 /admin 与 /admin/ 前缀(平台面不依赖租户域名)。
|
|
app.mount("/admin", AdminSPA(), name="admin")
|
|
|
|
|
|
@app.middleware("http")
|
|
async def static_no_cache(request, call_next):
|
|
"""静态资源协商缓存(2026-09-30 线上事件): 无 Cache-Control 时浏览器对
|
|
widget.js 走启发式缓存,修复部署后用户端仍跑旧脚本。no-cache=每次用
|
|
etag 轻量协商,有更新即时生效,未变则 304 几乎零开销。
|
|
/admin 同覆盖(终审微修): SPA 的 index.html 引用带 hash 的 assets——
|
|
管理员端旧 index 被启发式缓存会引旧 assets 白屏(与 widget.js 事件
|
|
同类);index 每次协商,hash 资源未变则 304,开销可忽略。"""
|
|
resp = await call_next(request)
|
|
path = request.url.path
|
|
if (path.startswith("/static/")
|
|
or path == "/admin" or path.startswith("/admin/")):
|
|
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(request: Request):
|
|
"""分级健康检查(Phase 4 Task 1:Docker healthcheck/部署观测的事实源)。
|
|
|
|
- 整体 status:任一已建 engine 探库失败 → "degraded" 但仍 200——单
|
|
租户库损坏不该让编排器杀容器(其余租户还在服务),也不整体 500;
|
|
- tenants:逐租户 slug/status(引擎配置态)+ engine_ok:True=探活
|
|
通过 / False=库损坏 / None=engine 懒建未就绪(不探——健康检查
|
|
不应成为触发全租户建库的路径);
|
|
- domains:域名绑定总数(导入完整性的人工核对口径)。
|
|
|
|
探活走租户自己的 session_factory(SELECT 1),任何异常都收敛成该
|
|
租户 degraded,不炸整体。
|
|
"""
|
|
registry = getattr(request.app.state, "registry", None)
|
|
tenants = []
|
|
overall = "ok"
|
|
domains = 0
|
|
if registry is not None:
|
|
for rt in registry.runtimes():
|
|
domains += sum(len(site.domains) for site in rt.config.sites)
|
|
engine_ok = None
|
|
if rt.session_factory is not None:
|
|
try:
|
|
async with rt.session_factory() as session:
|
|
await session.execute(text("SELECT 1"))
|
|
engine_ok = True
|
|
except Exception:
|
|
engine_ok = False
|
|
overall = "degraded"
|
|
tenants.append({
|
|
"slug": rt.config.slug,
|
|
"status": rt.config.status,
|
|
"engine_ok": engine_ok,
|
|
})
|
|
return {"status": overall, "tenants": tenants, "domains": domains}
|