# -*- coding: utf-8 -*- """园区子应用(app/park)—— FastAPI 子应用组装。 由 server-core/dispatcher.py 以 `/park` 前缀路由对外;独立 lifespan: 启动 MQTT hub、数据 tick 线程(sim_engine → publish_tick);s2s 实时语音栈延后。 """ from __future__ import annotations import asyncio import logging import threading from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from .config import settings from .event_bus import bus from .mqtt import hub from .routers import router from .sim_engine import get_engine, sim_engine from . import tenants log = logging.getLogger("dpm.park") settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True) @asynccontextmanager async def lifespan(app: FastAPI): # 园区表/种子由 alembic + scripts/db/seed.py 非运行态完成;此处仅启动运行时组件 bus.set_loop(asyncio.get_running_loop()) hub.start() stop = threading.Event() # s2s 实时语音栈(可选):后台线程起 RealtimeServer(:8765),仅 S2S_ENABLED=1 # 依赖 speech_to_speech(vendor/s2s-cloud,需先 `uv pip install -e app/park/vendor/s2s-cloud`) if settings.S2S_ENABLED: try: from .s2s_bridge import start_s2s_backend start_s2s_backend(stop) except Exception as e: # noqa: BLE001 log.warning("s2s 语音栈启动失败(S2S_ENABLED=1 但依赖未装?): %s", e) def tick_loop(): while not stop.is_set(): try: # 多租户:逐园区 tick + 按租户发布(同步读租户列表) for t in tenants.list_tenants_sync(): try: snap = get_engine(t["id"]).tick() hub.publish_tick(snap, t["id"]) except Exception as e: # noqa: BLE001 log.warning("园区 %s tick 异常: %s", t.get("id"), e) 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("园区子应用 /park 启动(MQTT %s:%s 系 %s)", settings.MQTT_HOST, settings.MQTT_PORT, "已启用" if settings.MQTT_ENABLED else "已禁用") try: yield finally: stop.set() hub.stop() app = FastAPI(title="OPC 智能园区子应用", version="1.0.0", lifespan=lifespan) # 跨域:大屏(Vite dev :1420 / Tauri tauri.localhost)与 server-core(:8090) 不同源,需放行 app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], allow_credentials=True, ) # 关键:内部路由本为 /api/*,挂载到 /park 下 → 外部地址 /park/api/* app.include_router(router, prefix="/park") # 媒体静态服务(上传/播放文件) app.mount("/park/file", StaticFiles(directory=str(settings.MEDIA_DIR)), name="park-media") # 管理后台静态资源(如有) _static_dir = Path(__file__).resolve().parent / "static" if _static_dir.exists(): app.mount("/park/static", StaticFiles(directory=str(_static_dir)), name="park-static")