Files
ai-kefu-admin/app/main.py
T
浅念 57adf8e2a6 feat(site-import): POST/GET API 与挂载
Task 7(spec §4):/api/platform/tenants/{slug}/site-import 两端点,
_tenant_guard 守租户(两角色)。POST 校验链:鉴权 → 租户存在/未停用
(404/403) → 形态预检(retry 只认 generate+task_id 非空;新建 URL 复用
safe_fetch._validate_url 单一事实源——scheme/内网字面量/端口,POST 即拒
400「仅支持公网网站地址」) → task.start;异常映射 ImportRateLimited→429 /
ConcurrentLimit→409 / LookupError→404 / ValueError→409(str(exc) 作
detail,任务层白话文案透出)。GET 五键 status/phase/pages/draft/error,
(slug,task_id) 联合索引即越权防线,过期 404「任务不存在或已过期」。
main.py 照 config_routes 位置挂载。测试 15 条(管线全打桩)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 21:33:00 +08:00

349 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.site_import_routes import (
router as platform_site_import_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)
# 从网站导入 API(site-import Task 7): POST 启动/GET 轮询(_tenant_guard,
# 两角色)——管线在 app/platform/site_import/task.py 的内存任务表,本层只做
# 校验与异常映射(429/409/404)。
app.include_router(platform_site_import_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}