# -*- coding: utf-8 -*- """OPC 智能园区后端 —— FastAPI 入口 启动:uvicorn app.main:app --host 0.0.0.0 --port 8000 或:python main.py """ import asyncio import logging import threading import time from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .admin import admin_login, admin_logout, admin_page from .config import settings from .event_bus import bus from .mqtt import hub from .routers import router from .sim_engine import sim_engine from .s2s_bridge import start_s2s_backend logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") log = logging.getLogger("dpm.main") DIST = Path(settings.DIST_DIR) @asynccontextmanager async def lifespan(app: FastAPI): # 启动准备 settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True) bus.set_loop(asyncio.get_running_loop()) hub.start() stop = threading.Event() # s2s 实时语音栈(可选):后台线程构建管线池并起 RealtimeServer(:8765) if settings.S2S_ENABLED: start_s2s_backend(stop) def tick_loop(): """数据引擎每 2.2s 推进一次;MQTT 连接时同步推送快照""" while not stop.is_set(): try: snap = sim_engine.tick() hub.publish_tick(snap) except Exception as e: # noqa: BLE001 log.warning("数据 tick 异常: %s", e) stop.wait(settings.MQTT_TICK_INTERVAL) threading.Thread(target=tick_loop, daemon=True).start() log.info("OPC 智能园区后端启动于 http://%s:%s", settings.HOST, settings.PORT) if settings.MQTT_ENABLED: log.info("MQTT Broker: %s:%s(前端 WS: %s)", settings.MQTT_HOST, settings.MQTT_PORT, settings.MQTT_WS_URL) try: yield finally: stop.set() hub.stop() log.info("OPC 智能园区后端已停止") app = FastAPI(title="OPC 智能园区后端", version="1.0.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def no_cache_admin_static(request: Request, call_next): """管理后台页面与静态资源禁用缓存,避免旧 JS/CSS 残留导致按钮无响应""" response = await call_next(request) if request.url.path.startswith(("/static/", "/admin")): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response app.include_router(router) # 媒体资源静态服务(上传/播放的文件) settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True) app.mount("/file", StaticFiles(directory=str(settings.MEDIA_DIR)), name="media") # 管理后台静态资源(admin.css / admin.js) _STATIC_DIR = Path(__file__).resolve().parent.parent / "static" app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") # 管理后台(FastAPI + Jinja2 服务端渲染)—— 必须在 SPA 回退之前注册 app.add_api_route("/admin", admin_page, methods=["GET"], include_in_schema=False) app.add_api_route("/admin/login", admin_login, methods=["POST"], include_in_schema=False) app.add_api_route("/admin/logout", admin_logout, methods=["GET"], include_in_schema=False) # ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ---------- @app.get("/{path:path}", include_in_schema=False) async def spa_fallback(path: str): if not DIST.exists(): return JSONResponse({"detail": "前端未构建(dist 目录不存在)"}, status_code=404) dist = DIST.resolve() if not path: target = dist / "index.html" else: target = (dist / path).resolve() if target.is_file() and target.is_relative_to(dist): return FileResponse(target) index = dist / "index.html" if index.exists(): return FileResponse(index) return JSONResponse({"detail": "Not Found"}, status_code=404)