Files
DPM/backend/app/main.py
T

188 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)