9f64b4273b
- s2s_bridge:同进程构建 s2s 管线池(silero VAD + dashscope ASR + qwen-plus + qwen3-tts)并起 RealtimeServer(:8765) - ThreadManager 启动管线 handler 线程;启动重试与完整配置日志 - config:DPM_S2S_* 系列配置(开关/端口/模型/重试/日志级别) - main lifespan 按 DPM_S2S_ENABLED 启停;pyproject 增加 s2s 依赖(httpx/nltk/scipy/torch 等)
121 lines
4.1 KiB
Python
121 lines
4.1 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 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)
|