188 lines
6.4 KiB
Python
188 lines
6.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""OPC 智能园区后端 —— FastAPI 入口
|
||
启动:uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||
或:python main.py
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
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 pydantic import BaseModel
|
||
|
||
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 回退之前注册) ----------
|
||
|
||
_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)
|
||
|
||
|
||
# ---------- 客户端更新检测(仅 Windows:比对版本 → 下发安装包下载地址) ----------
|
||
|
||
def _latest_installer():
|
||
"""返回 backend/ 下最新(按文件名排序取最大)的 *.setup.exe。"""
|
||
for f in sorted(_DOWNLOAD_ROOT.glob("*setup.exe"), reverse=True):
|
||
if f.is_file():
|
||
return f
|
||
return None
|
||
|
||
|
||
def _version_from_name(name: str) -> str:
|
||
"""从安装包文件名解析版本,如 `..._0.1.0_x64-setup.exe` → `0.1.0`。"""
|
||
m = re.search(r"_(\d+\.\d+(?:\.\d+)?)", name or "")
|
||
return m.group(1) if m else ""
|
||
|
||
|
||
def _version_key(v: str):
|
||
return tuple(int(x) for x in v.split(".") if x.isdigit())
|
||
|
||
|
||
class UpdateCheckBody(BaseModel):
|
||
current: str = "" # 前端上报的当前版本,如 0.1.0
|
||
|
||
|
||
@app.post("/api/update/check", include_in_schema=False)
|
||
async def update_check(body: UpdateCheckBody):
|
||
"""检测更新:返回最新版本号、安装包下载地址、是否需要更新(仅 Windows)。"""
|
||
inst = _latest_installer()
|
||
if not inst:
|
||
return {"ok": False, "error": "installer not found"}
|
||
latest = _version_from_name(inst.name)
|
||
current = (body.current or "").strip()
|
||
update_available = False
|
||
if latest and current and current != latest:
|
||
update_available = _version_key(latest) > _version_key(current)
|
||
elif latest and not current:
|
||
update_available = True
|
||
return {
|
||
"ok": True,
|
||
"latest_version": latest,
|
||
"installer_name": inst.name,
|
||
"download_url": "/download",
|
||
"update_available": update_available,
|
||
}
|
||
|
||
|
||
# ---------- 前端静态托管 + 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)
|