2026-08-17 21:25:46 +08:00
|
|
|
|
# -*- 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
|
|
|
|
|
|
|
2026-08-17 21:30:44 +08:00
|
|
|
|
from .admin import admin_login, admin_logout, admin_page
|
2026-08-17 21:25:46 +08:00
|
|
|
|
from .config import settings
|
|
|
|
|
|
from .event_bus import bus
|
|
|
|
|
|
from .mqtt import hub
|
|
|
|
|
|
from .routers import router
|
|
|
|
|
|
from .sim_engine import sim_engine
|
2026-08-18 01:37:48 +08:00
|
|
|
|
from .s2s_bridge import start_s2s_backend
|
2026-08-17 21:25:46 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-08-18 01:37:48 +08:00
|
|
|
|
# s2s 实时语音栈(可选):后台线程构建管线池并起 RealtimeServer(:8765)
|
|
|
|
|
|
if settings.S2S_ENABLED:
|
|
|
|
|
|
start_s2s_backend(stop)
|
|
|
|
|
|
|
2026-08-17 21:25:46 +08:00
|
|
|
|
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=["*"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-17 21:54:23 +08:00
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
2026-08-17 21:25:46 +08:00
|
|
|
|
app.include_router(router)
|
|
|
|
|
|
|
|
|
|
|
|
# 媒体资源静态服务(上传/播放的文件)
|
|
|
|
|
|
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
app.mount("/file", StaticFiles(directory=str(settings.MEDIA_DIR)), name="media")
|
|
|
|
|
|
|
2026-08-17 21:30:44 +08:00
|
|
|
|
# 管理后台静态资源(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)
|
|
|
|
|
|
|
2026-08-17 21:25:46 +08:00
|
|
|
|
|
2026-08-19 12:28:35 +08:00
|
|
|
|
# ---------- 客户端安装包下载(必须在 SPA 回退之前注册) ----------
|
|
|
|
|
|
|
|
|
|
|
|
_DOWNLOAD_ROOT = Path(__file__).resolve().parent.parent # backend/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/download", include_in_schema=False)
|
|
|
|
|
|
@app.get("/11", include_in_schema=False)
|
|
|
|
|
|
async def download_installer():
|
|
|
|
|
|
"""下载 Windows 客户端安装包(backend/ 下 *.setup.exe,如 云超服..._x64-setup.exe)。"""
|
|
|
|
|
|
for f in sorted(_DOWNLOAD_ROOT.glob("*setup.exe")):
|
|
|
|
|
|
if f.is_file():
|
|
|
|
|
|
return FileResponse(
|
|
|
|
|
|
path=str(f),
|
|
|
|
|
|
filename=f.name,
|
|
|
|
|
|
media_type="application/octet-stream",
|
|
|
|
|
|
)
|
|
|
|
|
|
return JSONResponse({"detail": "安装包不存在"}, status_code=404)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 21:25:46 +08:00
|
|
|
|
# ---------- 前端静态托管 + 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)
|