Files
ai-kefu-admin/app/main.py
T
浅念 a43c654337 feat: 头像上传(uuid重命名/魔数校验/2MB/配置版本管线)与三级解析链
- POST /api/platform/tenants/{slug}/brand/avatar(require_tenant):
  multipart 收文件,魔数判定类型(\x89PNG/\xff\xd8\xff/RIFF....WEBP,
  优先于扩展名——改后缀骗不过,存盘扩展名取自内容)、≤2MB(413,
  upload.size 预检免拷贝)、服务端 uuid4 重命名(原始文件名不参与
  路径),落 data/uploads/<slug>/(目录懒建,gitignore 已忽略)
- 走 Task 6 配置版本管线(复用 config_routes 内部 helper):读当前
  快照 → brand 组只替换 avatar 字段 → validate_group →
  write_config_version(comment=头像上传) → registry_reload 热生效
- /api/public/avatar 三级解析链:uploads → seed 资产 → 静态兜底;
  Phase 1 文件名穿越防线(拒 / \ . ..)扩到 uploads 段
- registry.uploads_root:自 data_dir.parent 派生(生产=<repo>/data/
  uploads;测试注入 tmp data_dir 时自动落 tmp 内,隔离免污染)
- 新依赖 python-multipart(multipart 表单解析)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:41 +08:00

139 lines
6.2 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, settings
from app.platform.db import (
bootstrap_superuser,
ensure_platform_schema,
make_platform_engine,
)
from app.platform.host_middleware import TenantHostMiddleware
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
PLATFORM_DB = BASE_DIR / "platform.db" # .gitignore 已忽略 platform.db*
@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
# 平台库测试缝(Phase 2 Task 1,同 registry 缝模式):预置过
# app.state.platform_engine(临时库)则沿用——不预置则每个 TestClient
# 测试都会在仓库根建出真实 platform.db。schema+bootstrap 无条件跑
# (两者幂等):预置引擎同样被建到可用,Task 2 起认证测试直接可用。
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
try:
await ensure_platform_schema(platform_engine)
await bootstrap_superuser(
platform_engine,
username=settings.ADMIN_USERNAME,
password=settings.ADMIN_PASSWORD)
# 询盘推送(设计 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.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 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"}