feat(park): 园区子应用 app/park 骨架迁入 — dispatcher /park 前缀分流

迁自 park-desktop/backend/app:config(数据目录→serverdata/park)/storage/sim_engine/
mqtt/event_bus + 精简 routers(health/settings/config/dashboard/park/companies/display/
events) + app.py 子应用(lifespan 起 MQTT hub+tick 线程, include_router prefix=/park)。
dispatcher.py 增 ROUTE_PARK_PREFIX + _prefix_app,与 app.training(/api)、app.main 分流。
pyproject 增 paho-mqtt(MQTT 必需)。AI/知识库/语音/视觉重模块延后全量迁入。
This commit is contained in:
Pine
2026-08-24 16:57:27 +08:00
parent 866f82dbc4
commit 4d4d38fe0f
11 changed files with 1041 additions and 5 deletions
View File
+67
View File
@@ -0,0 +1,67 @@
# -*- 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.staticfiles import StaticFiles
from .config import settings
from .event_bus import bus
from .mqtt import hub
from .routers import router
from .sim_engine import sim_engine
log = logging.getLogger("dpm.park")
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
bus.set_loop(asyncio.get_running_loop())
hub.start()
stop = threading.Event()
def tick_loop():
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("园区子应用 /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)
# 关键:内部路由本为 /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")
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""园区子应用(app/park)—— 全局配置
迁自 park-desktop/backend/app/config.py,数据目录改落 server-core/serverdata/park
环境变量沿用 server-core/.env(前缀 DPM_ 或 PARK_ 均兼容,MIG/PARK 二选一)。
"""
import os
from pathlib import Path
SERVER_ROOT = Path(__file__).resolve().parent.parent.parent # server-core 根
PARK_DIR = Path(os.environ.get("PARK_DATA_DIR", str(SERVER_ROOT / "serverdata" / "park")))
def _load_dotenv(path):
"""极简 .env 解析:KEY=VALUE,支持 # 注释与引号"""
if not Path(path).exists():
return
for raw in Path(path).read_text("utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
_load_dotenv(SERVER_ROOT / ".env")
def _env(key, default):
return os.getenv(key, default)
class Settings:
# ---- HTTP ----
HOST = _env("DPM_HOST", _env("FASTAPI_HOST", "0.0.0.0"))
PORT = int(_env("DPM_PORT", _env("FASTAPI_PORT", "8000")))
# ---- 数据目录(统一落 serverdata/park----
MEDIA_DIR = Path(_env("DPM_MEDIA_DIR", str(PARK_DIR / "media")))
DATA_FILE = Path(_env("DPM_DATA_FILE", str(PARK_DIR / "data.json")))
DIST_DIR = Path(_env("DPM_DIST_DIR", str(PARK_DIR / "dist"))) # 大屏前端产物(可选托管)
# ---- 视频帧保存(摄像头识别帧)----
SAVE_VISION_FRAMES = _env("DPM_SAVE_VISION_FRAMES", "1") == "1"
VISION_FRAME_SAVE_DIR = Path(_env("DPM_VISION_SAVE_DIR", str(MEDIA_DIR / "video")))
# ---- MQTTEMQX broker;凭据走 server-core/.env,勿入库)----
MQTT_ENABLED = _env("DPM_MQTT_ENABLED", "1") == "1"
MQTT_HOST = _env("DPM_MQTT_HOST", _env("MQTT_BROKER_HOST", "192.168.1.9"))
MQTT_PORT = int(_env("DPM_MQTT_PORT", _env("MQTT_BROKER_PORT", "1883")))
MQTT_USERNAME = _env("DPM_MQTT_USERNAME", _env("MQTT_USERNAME", "")) or None
MQTT_PASSWORD = _env("DPM_MQTT_PASSWORD", _env("MQTT_PASSWORD", "")) or None
MQTT_CLIENT_ID = _env("DPM_MQTT_CLIENT_ID", "dpm-backend")
MQTT_WS_URL = _env(
"DPM_MQTT_WS",
f"ws://{_env('MQTT_PUBLIC_HOST', _env('MQTT_BROKER_HOST', 'localhost'))}:8083/mqtt",
)
MQTT_TICK_INTERVAL = float(_env("DPM_MQTT_TICK_INTERVAL", "2.2"))
# ---- 主题 ----
TOPIC_COMMAND = _env("DPM_TOPIC_COMMAND", "opc/display/command")
TOPIC_TICK = _env("DPM_TOPIC_TICK", "opc/dashboard/tick")
TOPIC_ACK = _env("DPM_TOPIC_ACK", "opc/display/ack")
TOPIC_HEARTBEAT = _env("DPM_TOPIC_HEARTBEAT", "opc/display/heartbeat")
SCREEN_TTL = float(_env("DPM_SCREEN_TTL", "30"))
# ---- 阿里云 DashScopeLLM + 语音识别)----
DASHSCOPE_API_KEY = _env("DASHSCOPE_API_KEY", "")
LLM_MODEL = _env("DPM_LLM_MODEL", "qwen-plus")
ASR_MODEL = _env("DPM_ASR_MODEL", "paraformer-realtime-v2")
LLM_BASE_URL = _env("DPM_LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
ASR_TIMEOUT = float(_env("DPM_ASR_TIMEOUT", "30"))
# ---- 管理后台 ----
ADMIN_SECRET = _env("DPM_ADMIN_SECRET", "dpm-admin-secret-change-me")
# ---- s2s 实时语音栈(VAD本地 + 云ASR/LLM/TTS----
S2S_ENABLED = _env("DPM_S2S_ENABLED", "0") == "1"
S2S_HOST = _env("DPM_S2S_HOST", "0.0.0.0")
S2S_PORT = int(_env("DPM_S2S_PORT", "8765"))
S2S_NUM_PIPELINES = int(_env("DPM_S2S_NUM_PIPELINES", "1"))
VISION_LLM_MODEL = _env("DPM_VISION_LLM_MODEL", "qwen3-vl-flash")
S2S_STT_MODEL = _env("DPM_S2S_STT_MODEL", "qwen3-asr-flash-realtime")
S2S_LLM_MODEL = _env("DPM_S2S_LLM_MODEL", "qwen-plus")
S2S_TTS_MODEL = _env("DPM_S2S_TTS_MODEL", "qwen3-tts-flash-realtime")
S2S_TTS_VOICE = _env("DPM_S2S_TTS_VOICE", "Cherry")
S2S_BUILD_RETRIES = int(_env("DPM_S2S_BUILD_RETRIES", "4"))
S2S_BUILD_RETRY_DELAY = float(_env("DPM_S2S_BUILD_RETRY_DELAY", "10"))
S2S_LOG_LEVEL = _env("DPM_S2S_LOG_LEVEL", "INFO").upper()
VOICE_WS = _env("DPM_VOICE_WS", "")
S2S_WS_URL = (
VOICE_WS
or f"ws://{_env('MQTT_PUBLIC_HOST', _env('MQTT_BROKER_HOST', 'localhost'))}:{S2S_PORT}/v1/realtime"
)
S2S_INSTRUCTIONS = _env(
"DPM_S2S_INSTRUCTIONS",
"你是昆小创,昆明市大学生创业园的专属智能语音助手。可用工具:get_park_overview(园区实时数据)、"
"query_companies(企业名录)、control_display(大屏控制)、get_time(时间)。"
"涉及园区数据/企业/大屏控制时务必调用工具获取准确信息。请用简洁专业的中文回答,不超过三句话。",
)
settings = Settings()
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""SSE 事件总线 —— 兼容旧通道(前端 MQTT 不可用时回退)
所有 MQTT 控制指令同时推送到 SSE /api/events
"""
import asyncio
import json
class EventBus:
def __init__(self):
self._queues = set()
self._loop = None
def set_loop(self, loop):
self._loop = loop
def subscribe(self):
q = asyncio.Queue(maxsize=256)
self._queues.add(q)
return q
def unsubscribe(self, q):
self._queues.discard(q)
def emit(self, payload: dict):
"""线程安全:从任意线程广播事件"""
if not self._loop:
return
try:
asyncio.run_coroutine_threadsafe(self._emit(payload), self._loop)
except Exception: # noqa: BLE001
pass
async def _emit(self, payload):
data = json.dumps(payload, ensure_ascii=False)
for q in list(self._queues):
try:
q.put_nowait(data)
except asyncio.QueueFull:
pass
bus = EventBus()
+242
View File
@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
"""MQTT 发布/订阅中心 —— 后端 ↔ 前端控制通道
发布:opc/display/command(页面/媒体/卡片控制)、opc/dashboard/tick(数据快照)
订阅:opc/display/heartbeat(大屏在线心跳,统计在线大屏数)
"""
import json
import logging
import os
import threading
import time
import uuid
import paho.mqtt.client as mqtt
from .config import settings
from .event_bus import bus
log = logging.getLogger("dpm.mqtt")
def new_cmd_id():
return uuid.uuid4().hex[:12]
def _unique_client_id(base):
"""每个客户端进程使用唯一 client_id,避免与其它实例竞争被 broker 踢下线"""
return f"{base}-{os.getpid()}-{uuid.uuid4().hex[:6]}"
class MqttHub:
def __init__(self):
self.client = None
self.connected = False
self._lock = threading.RLock()
self.devices = {} # device_id(client_id) -> {role, parent_id, last_seen}
self.last_command = None # {action, params, ts, published}
# ---------- 生命周期 ----------
def start(self):
if not settings.MQTT_ENABLED:
log.info("MQTT 已禁用(DPM_MQTT_ENABLED=0")
return
try:
self.client = mqtt.Client(
client_id=_unique_client_id(settings.MQTT_CLIENT_ID),
clean_session=True,
protocol=mqtt.MQTTv311,
)
if settings.MQTT_USERNAME:
self.client.username_pw_set(settings.MQTT_USERNAME, settings.MQTT_PASSWORD)
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
self.client.connect_async(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=30)
self.client.loop_start()
log.info("MQTT 连接中 %s:%s ...", settings.MQTT_HOST, settings.MQTT_PORT)
except Exception as e: # noqa: BLE001
log.warning("MQTT 初始化失败(数据 API 不受影响): %s", e)
def stop(self):
if self.client:
try:
self.client.loop_stop()
self.client.disconnect()
except Exception: # noqa: BLE001
pass
self.client = None
def _on_connect(self, client, userdata, flags, rc):
if rc == 0:
self.connected = True
log.info("MQTT 已连接 %s:%s", settings.MQTT_HOST, settings.MQTT_PORT)
client.subscribe(settings.TOPIC_HEARTBEAT, qos=0)
else:
log.warning("MQTT 连接失败 rc=%s", rc)
def _on_disconnect(self, client, userdata, rc):
self.connected = False
if rc != 0:
log.warning("MQTT 断开(rc=%s),自动重连中...", rc)
def _on_message(self, client, userdata, msg):
"""接收大屏心跳:更新设备表在线时间(设备由注册表登记 role/parent"""
if msg.topic == settings.TOPIC_HEARTBEAT:
try:
payload = json.loads(msg.payload.decode("utf-8"))
cid = payload.get("client_id") or msg.topic
with self._lock:
cur = self.devices.get(cid, {"role": "", "parent_id": ""})
cur["last_seen"] = time.time()
self.devices[cid] = cur
self._prune()
except Exception: # noqa: BLE001
pass
def _prune(self):
"""剔除超时未上报的设备"""
cutoff = time.time() - settings.SCREEN_TTL
self.devices = {k: v for k, v in self.devices.items() if v.get("last_seen", 0) > cutoff}
# ---------- 状态查询 ----------
def screens_online(self):
with self._lock:
return sum(1 for v in self.devices.values() if v.get("last_seen", 0) > time.time() - settings.SCREEN_TTL)
def status(self):
with self._lock:
self._prune()
devices = [
{
"device_id": cid,
"role": v.get("role", ""),
"parent_id": v.get("parent_id", ""),
}
for cid, v in self.devices.items()
]
return {
"mqtt_connected": self.connected,
"mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}",
"screens_online": len(devices),
"devices": devices,
"last_command": self.last_command,
}
# ---------- 发布 ----------
def publish(self, topic, payload, qos=1, retain=False):
if not self.client or not self.connected:
log.debug("MQTT 未连接,丢弃发布 %s", topic)
return False
try:
info = self.client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=qos, retain=retain)
return info.rc == mqtt.MQTT_ERR_SUCCESS
except Exception as e: # noqa: BLE001
log.warning("MQTT 发布失败: %s", e)
return False
def register_device(self, device_id, role="", parent_id=""):
"""前端上报设备:唯一 device_id + 角色(main/secondary) + 从属主屏 parent_id。"""
device_id = (device_id or "").strip()
if not device_id:
return
with self._lock:
cur = self.devices.get(device_id, {})
if role:
cur["role"] = role
if parent_id:
cur["parent_id"] = parent_id
cur["last_seen"] = time.time()
self.devices[device_id] = cur
self._prune()
log.info("mqtt: 设备注册 id=%s role=%s parent=%s", device_id, role or cur.get("role"), parent_id or cur.get("parent_id"))
def publish_command(self, action, params=None, screen_id=None, screen_role=None):
"""按客户端(设备)路由下发指令:
· screen_id 精确到某台设备 → 只发到该设备独有 topic
· screen_role(main/secondary) → 解析到该角色的设备,发到其独有 topic
· 两者皆空(全部)→ 遍历在线设备列表,依次下发到各自独有 topic
客户端只订阅自己的独有频道,因此定向指令绝不会被其它屏幕收到。"""
payload = {
"cmd_id": new_cmd_id(),
"ts": int(time.time() * 1000),
"action": action,
"params": params or {},
}
target = None
requested = bool(screen_id or screen_role in ("main", "secondary"))
if screen_id:
target = (screen_id or "").strip()
elif screen_role in ("main", "secondary"):
with self._lock:
for cid, v in self.devices.items():
if v.get("role") == screen_role:
target = cid
break
if target:
# 定向:只发到目标设备独有频道
return self._publish_to(payload, target, screen_role)
if requested:
# 指定了目标但未找到对应设备:不广播,避免误发到全部
log.warning("mqtt: 目标设备未找到(id=%s role=%s),未下发", screen_id, screen_role)
payload["published"] = False
with self._lock:
self.last_command = {"action": action, "params": params or {}, "ts": payload["ts"], "published": False, "topic": "none"}
return payload
# 全部:遍历在线设备列表,依次下发到各自独有频道
with self._lock:
cutoff = time.time() - settings.SCREEN_TTL
online = [cid for cid, v in self.devices.items() if v.get("last_seen", 0) > cutoff]
ok = True
published_topics = []
for cid in online:
p = dict(payload)
p["screen_id"] = cid
with self._lock:
p["screen_role"] = self.devices.get(cid, {}).get("role", "")
if self.publish(f"{settings.TOPIC_COMMAND}/{cid}", p):
bus.emit(p)
published_topics.append(cid)
else:
ok = False
payload["published"] = ok
payload["screen_id"] = ""
payload["screen_role"] = "both"
with self._lock:
self.last_command = {
"action": action,
"params": params or {},
"ts": payload["ts"],
"published": ok,
"topic": "all",
}
log.info("mqtt: 全部下发 %s%d 台设备", action, len(published_topics))
return payload
def _publish_to(self, payload, target, screen_role=""):
"""发到指定设备独有频道,并记录 last_command。"""
topic = f"{settings.TOPIC_COMMAND}/{target}"
payload["screen_id"] = target
with self._lock:
payload["screen_role"] = self.devices.get(target, {}).get("role", screen_role or "")
ok = self.publish(topic, payload)
payload["published"] = ok
with self._lock:
self.last_command = {
"action": payload["action"],
"params": payload.get("params") or {},
"ts": payload["ts"],
"published": ok,
"topic": topic,
}
if ok:
bus.emit(payload)
return payload
def publish_tick(self, snapshot):
payload = {"ts": int(time.time() * 1000), "snapshot": snapshot}
self.publish(settings.TOPIC_TICK, payload, qos=0)
hub = MqttHub()
+179
View File
@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
"""园区子应用(app/park)—— 大屏控制 / 数据 / 设置 / 园区企业(骨架版)。
迁自 park-desktop/backend/app/routers.py,本骨架仅含不依赖重模块(llm/rag/asr/
s2s/vision)的大屏控制端点;AI/知识库/语音/视觉在后续全量迁移补齐。
全部端点经 dispatcher `/park` 前缀暴露(include_router 挂 `/park`)。
"""
from __future__ import annotations
import json
import logging
import time
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from .config import settings
from .event_bus import bus
from .mqtt import hub
from .sim_engine import sim_engine
from .storage import storage
log = logging.getLogger("dpm.api")
router = APIRouter()
# ==================== 数据模型 ====================
class SettingsBody(BaseModel):
volume: int | None = None
sfx_volume: int | None = None
play_mode: str | None = None
image_duration: int | None = None
fullscreen: bool | None = None
autostart: bool | None = None
username: str | None = None
password: str | None = None
class DisplayCommandBody(BaseModel):
action: str
params: dict = Field(default_factory=dict)
screen_id: str = ""
screen_role: str = ""
class RegisterBody(BaseModel):
device_id: str = ""
role: str = ""
parent_id: str = ""
# ==================== 健康 / 设置 / 引导 ====================
@router.get("/api/health")
async def health():
return {
"ok": True,
"service": "opc-park",
"mqtt_connected": hub.connected,
"screens_online": hub.screens_online(),
"version": "1.0.0",
}
@router.get("/api/settings")
async def get_settings():
return storage.get_settings()
@router.post("/api/settings")
async def update_settings(body: SettingsBody):
patch = body.model_dump(exclude_none=True)
storage.update_settings(**patch)
hub.publish_command("settings_changed", patch)
return storage.get_settings()
@router.get("/api/config")
async def runtime_config(request: Request):
"""大屏启动引导:下发 MQTT/语音接入地址与 tick 间隔。"""
return {
"mqtt": {
"host": settings.MQTT_HOST,
"port": settings.MQTT_PORT,
"ws_url": settings.MQTT_WS_URL,
"tick_interval": settings.MQTT_TICK_INTERVAL,
},
"voice_ws": settings.S2S_WS_URL if settings.S2S_ENABLED else "",
"media_base": f"{request.base_url}park/file",
"api_base": f"{request.base_url}park/api",
}
# ==================== 大屏数据(sim_engine ====================
@router.get("/api/dashboard/snapshot")
async def dashboard_snapshot():
return sim_engine.snapshot()
@router.get("/api/dashboard/overview")
async def dashboard_overview():
return sim_engine.snapshot()
@router.get("/api/park/companies")
async def park_companies():
snap = sim_engine.snapshot()
return snap.get("companies", [])
@router.get("/api/park/zones")
async def park_zones():
snap = sim_engine.snapshot()
return snap.get("zones", [])
# ==================== 展示控制(管理端 → MQTT ====================
# 全部 MQTT 前端控制命令白名单(与 park-desktop docs/mqtt-commands.md 一致)
_VALID_DISPLAY_ACTIONS = {
"navigate", "navigate_rel",
"vision_set",
"ai_input", "ai_preset", "ai_company", "ai_zone",
"voice_start", "voice_stop", "voice_refresh",
"play", "pause", "next", "prev", "set_mode", "play_target",
"dual_screen",
"alert", "show_card", "minimize", "settings_changed", "playlist_changed",
}
@router.post("/api/display/register")
async def display_register(body: RegisterBody):
hub.register_device(body.device_id, body.role, body.parent_id)
return {"ok": True, "device_id": body.device_id, "role": body.role, "parent_id": body.parent_id}
@router.post("/api/display/command")
async def display_command_publish(body: DisplayCommandBody):
if body.action not in _VALID_DISPLAY_ACTIONS:
return JSONResponse({"ok": False, "error": f"不支持的指令: {body.action}"}, status_code=400)
cmd = hub.publish_command(body.action, body.params, body.screen_id or None, body.screen_role or None)
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
"screen_id": body.screen_id or "", "screen_role": body.screen_role or "both",
"mqtt_connected": hub.connected}
@router.get("/api/display/state")
async def display_state():
return hub.status()
# ==================== SSE 兼容通道(MQTT 不可用时前端回退) ====================
@router.get("/api/events")
async def sse_events(request: Request):
async def gen():
q = bus.subscribe()
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio_timeout(q.get(), 15)
yield f"data: {data}\n\n"
except Exception: # noqa: BLE001
yield ": keepalive\n\n"
finally:
bus.unsubscribe(q)
return StreamingResponse(gen(), media_type="text/event-stream")
def asyncio_timeout(awaitable, seconds):
"""极简超时包装:避免依赖 asyncio.timeout3.11+ 的上下文管理器)。"""
import asyncio
return asyncio.wait_for(awaitable, timeout=seconds)
+262
View File
@@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
"""园区数据引擎(后端)—— 真实数据 + 测算演示
数据来源(用户指定口径):
· 《昆明市大学生创业园运行情况统计表(2026年7月).xls》(官方月报)
· 《昆明市创业园宣传册.pdf》
测算口径(用户确认):
· TOKEN:真实业务锚点月均 200 亿 tokens → 日均 ≈ 6.67 亿(200亿/30),
实时速率 ≈ 7,700 t/s(日均/86400s),近30日累计 = 200 亿
· 工具调用:按平均每次调用消耗约 2 万 tokens 折算(今日 ≈ 3.3 万次)
其余统计指标为真实静态值;TOKEN/工具仅做轻微演示波动
"""
import random
import threading
import time
# ---------- 真实锚点(仅来自 xls 统计表 + 附件1 省级材料 + 宣传册) ----------
REAL = {
"as_of": "2026-07",
"token_monthly_yi": 200, # 月均消耗 200 亿 tokens(AI 平台业务锚点,非政府统计口径)
"token_daily_wan": 66666.7, # 日均 6.67 亿 tokens200亿/30 ≈ 66666.7 万)
"token_rate": 7700, # 实时 t/s6.67e8 / 86400 ≈ 7,716
"tools_per_call_tokens": 150000, # 平均每次工具调用消耗 tokens(真实测算:12.5M输入 ÷ 81步 ≈ 15.4万)
"occupied": 38, # 实际入驻企业数(官方口径:《2026年7月运行情况统计表》38 家)
"capacity": 49, # 园区可容纳企业个数(统计表)
"invested": 560, # 开园以来累计投入运营资金(万元,统计表)
"jobs": 213, # 带动就业人数(统计表,当年累计口径)
"revenue_total": 252.15, # 生产经营总额(万元,统计表,当年累计口径)
"revenue_tax": 1.03, # 上缴税利总额(万元,统计表,当年累计口径)
"incubated_total": 181, # 累计孵化企业(附件1 省级绩效累计口径,截至2026年5月)
"in_incubation": 31, # 当前实有在孵创业实体(附件1
"graduated": 149, # 累计成功孵化出园企业(统计表,开园以来累计)
"patents": 171, # 入驻团队发明专利(附件1 累计口径)
"jobs_total": 1529, # 累计带动就业(附件1 省级绩效累计口径)
"revenue_total_cum": 21911.14, # 入驻团队累计经营收入(万元,附件1 累计口径 ≈ 2.19 亿元)
"revenue_tax_cum": 426.2, # 入驻团队累计税收(万元,附件1 累计口径)
"mentors": 50, # 创业指导专家团队(附件1
"success_rate": 95.91, # 近3年入孵实体孵化成功率(%)(附件1)
"area": 3000, # 园区建筑面积(㎡,统计表+宣传册)
"founded": 2009, # 成立年份(宣传册)
"province_level": 2012, # 获评省级创业园年份(宣传册)
"address": "昆明市民航路229号(人力资源中心1-2楼)",
"phone": "18087174891",
"email": "714066050@qq.com",
}
# 入驻企业名录(《入驻企业信息表》39 家全称,与在园数统一口径)
COMPANIES = [
"云南派音人工智能科技有限公司", "米勒克尔蓝宝石珠宝产业化(项目)", "中泰研学合作(项目)",
"云南宸中低空经济有限公司", "昆明智海银高文化科技有限公司", "人工智能机器人大模型训练(项目)",
"云南廷秀文旅康养服务(项目)", "瀚颖AI+教育信息咨询(项目)", "云南大学AI+联合创业服务平台(项目)",
"仰光客厅(项目)", "云南上古绝学文化发展有限公司", "千楣之路(项目)",
"中越生物医疗(项目)", "中越文旅交流(项目)", "绿态环保科技项目",
"联翼航空技术低空经济应用国际化拓展(项目)", "酷享野农AI农业(项目)", "滇缅国际设计(项目)",
"昆明舒诺生物科技有限责任公司", "达岸教育管理(云南)有限公司", "昆明滇油石化有限责任公司",
"花仙子园艺肥料(云南)有限公司", "Facebook越南市场普洱茶与建水紫陶壶跨境电商(项目)",
"研X——同行者网络(项目)", "鬼才明AI创意工作室(项目)", "朵哈·玫瑰特色产业链(项目)",
"昆明云韵体育有限责任公司", "昆明舒护安养老服务有限责任公司",
"南菌优培—食用菌专用生物制剂创新与高原特色野生菌高值化全链开发(项目)",
"五华区丽裳文化艺术工作室", "云南星瑞航空(项目)", "综合性云南直播私域服务平台",
"昆明屿澈电子商务有限公司", "中外青少年研学(项目)", "星杭商务信息咨询昆明有限责任公司",
"蓝智科技(项目)", "云品出滇·纸享万家(项目)",
"云迹轻旅—昆明本土一站式轻量化文旅服务工作室(项目)", "启元人工智能科技(项目)",
]
# 宣传册四大孵化区域
ZONES = [
{"name": "OPC创业空间", "desc": "AI 技术支撑 · 数字经济/AI应用/软件开发/文创设计/电商直播等轻资产领域", "period": "拎包入驻 · 最长2年"},
{"name": "创业苗圃区", "desc": "创意阶段 · 未注册企业团队 · 开放式共享工位", "period": "最长2年"},
{"name": "孵化加速区", "desc": "已工商注册初创企业 · 独立办公空间", "period": "基础2年 · 可延1年(≤3年)"},
{"name": "国际创客区", "desc": "归国留学生 · 外籍留学生 · 国际化配套服务", "period": "参照加速区"},
]
# 园区企业行业分布(按《入驻企业信息表》39 家企业简介分类统计)
INDUSTRY_MIX = [
{"name": "人工智能", "value": 7},
{"name": "跨境电商", "value": 5},
{"name": "教育培训", "value": 4},
{"name": "文旅康养", "value": 4},
{"name": "文化创意", "value": 4},
{"name": "生物医药", "value": 4},
{"name": "低空经济", "value": 3},
{"name": "现代农业", "value": 3},
{"name": "绿色环保", "value": 2},
{"name": "企业服务", "value": 2},
{"name": "新材料", "value": 1},
]
# 园区实时动态(真实事件:园区官方公示 + 宣传册活动 + 互联网公开信息,最新在前)
FEED = [
{"id": 24, "icon": "dna", "text": "园内企业「昆明舒诺生物科技」完成GEO服务体系建设(生成式引擎优化,聚焦宠物营养品牌数字化)", "time": "2026-08"},
{"id": 1, "icon": "activity", "text": "2026年7月运行情况统计表填报完成:累计投入运营资金560万元、带动就业213人", "time": "2026-07-31"},
{"id": 2, "icon": "radio", "text": "2026年第三期入驻创业企业(项目)评审结果在昆明市人社局官网公示", "time": "2026-07-02"},
{"id": 3, "icon": "graduation", "text": "云南师范大学就业创业实践基地落地园区 · 云南旅游职业学院开展就业创业交流", "time": "2026-06-03"},
{"id": 23, "icon": "radio", "text": "「PineSound」完成 ICP 备案(滇ICP备2026008517号)与公安备案,注册地址昆明市民航路301-307号", "time": "2026"},
{"id": 4, "icon": "rocket", "text": "\"滇水逐梦·雨林创航\"昆明·西双版纳创业路演PK赛成功举办", "time": "2026-05-20"},
{"id": 5, "icon": "flag", "text": "昆明市青年企业家协会创新创业基地在园区揭牌", "time": "2026-05-13"},
{"id": 6, "icon": "users", "text": "\"聚力赋能·共创未来\"高校创新创业交流暨项目路演活动圆满举行", "time": "2026-05-08"},
{"id": 7, "icon": "trophy", "text": "\"创赢未来\"2026创业大赛昆明选拔赛暨马兰花创业培训讲师大赛举行(13个项目参赛)", "time": "2026-04-22"},
{"id": 8, "icon": "broadcast", "text": "2026年第二期创业企业(项目)入驻招募公告发布(官网 www.kmyc.gov.cn", "time": "2026-04-15"},
{"id": 21, "icon": "sparkles", "text": "「PineSound」AI驱动音频管理平台上线 pinesound.cn:智能配乐与音效生成,集成100W+音效库、50W+配乐库,全球版权授权(官网)", "time": "2026"},
{"id": 22, "icon": "medal", "text": "PineSound发布企业标准 Q/YNPY 001-2026《数字内容创作 配乐通用分类标准》", "time": "2026"},
{"id": 9, "icon": "award", "text": "2026年第一期入驻评审结果在昆明市人社局官网公示(23个优质项目正式入驻)", "time": "2026-03-27"},
{"id": 10, "icon": "users", "text": "2026年第一期招募集中评审完成:38个申请,23个优质项目正式入驻", "time": "2026-03-24"},
{"id": 19, "icon": "sparkles", "text": "云南派音人工智能科技专注多模态音频技术研发:自研Pine系列模型覆盖音频识别、向量嵌入、音效生成、配乐创作", "time": "2026-04"},
{"id": 20, "icon": "bolt", "text": "「PineSound」2026年4月注册成立,获北京投资支持并吸纳就业4人,入驻加速区(入驻企业信息表)", "time": "2026-04"},
{"id": 11, "icon": "cpu", "text": "云南首个人工智能OPC创新人才基地落地昆明,填补省内个体AI创业培育空白(公开报道)", "time": "2026-03-23"},
{"id": 12, "icon": "medal", "text": "第九届\"春城创业荟\"创业创新大赛圆满闭幕,获奖项目名单公布", "time": "2025-09-30"},
{"id": 13, "icon": "trophy", "text": "\"春城创业荟\"初赛落幕,147个项目晋级复赛", "time": "2025-04-28"},
{"id": 14, "icon": "building", "text": "园内企业「花仙子园艺肥料(云南)」成立,注册资本118万元(公开报道)", "time": "2025-04-28"},
{"id": 15, "icon": "radio", "text": "2025年入园项目评审结果公示(昆明市人社局官网)", "time": "2025-04-22"},
{"id": 16, "icon": "atom", "text": "研X平台入选\"创客中国\"项目库(一站式研究生学术成长平台)", "time": "2025-01"},
{"id": 17, "icon": "leaf", "text": "行业动态:园内「朵哈·玫瑰」原料产地昆明八街食用玫瑰行情上涨,玫瑰经济走强", "time": "2025-01"},
{"id": 18, "icon": "heart", "text": "行业动态:「千楣之路」主营檀娜卡——泰国天然护肤品牌Thanaka正式进入中国市场", "time": "2025-01"},
]
def _day_secs():
"""当日零点起已过秒数(时钟驱动,保证数值单向持续增长、重启连续)"""
now = time.time()
lt = time.localtime(now)
day_start = time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, 0, 0, -1))
return max(0.0, now - day_start)
def _today_values(prev_noise=None):
"""按真实速率推算当日累计值(单向增长 + 工具调用波动):
平台实时速率 = 单会话 157 tok/s × 49 路并发 ≈ 7,700 t/s
今日 TOKEN(万)= 秒数 × 7,700 / 10000 = 秒数 × 0.77
今日工具调用 = 今日 TOKEN / 15万 tokens 每次(真实基线)
+ 随机游走噪声(±10,波浪式波动,不单调)
"""
secs = _day_secs()
today_wan = round(secs * 0.77) # 万 tokens
base_tools = secs * 0.77 * 10000 / REAL["tools_per_call_tokens"] # 真实基线(浮点)
if prev_noise is None:
noise = 0.0
else:
noise = max(-10.0, min(10.0, prev_noise + (random.random() - 0.5) * 6.0))
today_tools = max(0, round(base_tools + noise)) # 次
return today_wan, today_tools, noise
def _make_series(base, noise, n=30):
"""生成围绕日均锚点的演示曲线(单位:万 tokens),尾点=今日实时值"""
out = []
for _ in range(n - 1):
v = base + (random.random() - 0.5) * noise
out.append(round(max(1, v), 1))
out.append(round(base, 1))
return out
def _live_values(prev=None):
"""TOKEN 面板实时运行指标(围绕真实基准动态波动,会话持续推进)"""
r = random.random
steps = (prev or {}).get("steps", 81)
steps += 1 if r() < 0.3 else 0
return {
"firstToken": round(1.3 + (r() - 0.5) * 0.2, 2), # 首 token 平均 1.3s 基准 ±0.1
"throughput": round(157 + (r() - 0.5) * 14), # 吞吐 157 tok/s 基准 ±7
"cacheHit": round(98 + (r() - 0.5) * 1.2, 1), # 缓存命中 98% 基准 ±0.6
"inputM": round((prev or {}).get("inputM", 12.5) + 0.004 + r() * 0.008, 2), # 会话输入持续推进
"outputM": round((prev or {}).get("outputM", 2.0) + 0.001 + r() * 0.002, 2), # 会话输出持续推进
"rounds": max(2, steps // 40), # 每 40 步一轮
"steps": steps, # 步数持续增长
}
def init_snapshot():
today_wan, today_tools, noise = _today_values()
return {
"as_of": REAL["as_of"],
"_toolsNoise": noise,
"live": _live_values(),
"token": {
"today": today_wan, # 万 tokens(当日累计,实时增长)
"total": REAL["token_monthly_yi"] * 10000 + today_wan, # 万 tokens(近30日 = 200亿 + 今日增量)
"rate": REAL["token_rate"], # t/s157 tok/s × 49 并发 ≈ 7,700
"series": _make_series(REAL["token_daily_wan"], REAL["token_daily_wan"] * 0.16),
},
"tools": {
"today": today_tools, # 次(随 TOKEN 实时联动)
"total": round(REAL["token_monthly_yi"] * 100000000 / REAL["tools_per_call_tokens"]) + today_tools, # 次
"success": 98.5, # 成功率(演示)
"nodes": 12, # AI 服务节点(演示)
},
"projects": {
"inPark": REAL["occupied"], # 实际入驻 38 家(2026年7月统计表官方口径)
"capacity": REAL["capacity"], # 可容纳 49 个
"invested": REAL["invested"], # 累计投入 560 万元
},
"jobs": {"total": REAL["jobs"]}, # 带动就业 213 人(当年累计口径)
"revenue": {"total": REAL["revenue_total"], "tax": REAL["revenue_tax"]}, # 万元(当年累计口径)
"cumulative": { # 省级绩效累计口径(附件1,截至2026年5月)
"incubated": REAL["incubated_total"], # 累计孵化企业 181 家
"inIncubation": REAL["in_incubation"], # 当前实有在孵 31 家
"graduated": REAL["graduated"], # 累计成功出园 149 家
"patents": REAL["patents"], # 发明专利 171 项
"jobs": REAL["jobs_total"], # 累计带动就业 1,529 人
"revenue": REAL["revenue_total_cum"], # 累计经营收入 21,911.14 万元
"tax": REAL["revenue_tax_cum"], # 累计税收 426.2 万元
"mentors": REAL["mentors"], # 创业指导专家团队 50 人
"successRate": REAL["success_rate"], # 近3年孵化成功率 95.91%
},
"park": {
"area": REAL["area"],
"founded": REAL["founded"],
"provinceLevel": REAL["province_level"],
"address": REAL["address"],
"phone": REAL["phone"],
"email": REAL["email"],
},
"zones": ZONES,
"companies": COMPANIES,
"industryMix": INDUSTRY_MIX,
"feed": FEED,
}
def next_snapshot(s):
"""时钟驱动:按真实速率重算当日累计(TOKEN 单向增长,工具调用波浪波动),其余真实静态"""
today_wan, today_tools, noise = _today_values(s.get("_toolsNoise"))
token = {
"today": today_wan,
"total": REAL["token_monthly_yi"] * 10000 + today_wan,
"rate": REAL["token_rate"],
"series": s["token"]["series"][:-1] + [today_wan],
}
tools = {
"today": today_tools,
"total": round(REAL["token_monthly_yi"] * 100000000 / REAL["tools_per_call_tokens"]) + today_tools,
"success": 98.5,
"nodes": 12,
}
return {
**s,
"_toolsNoise": noise,
"live": _live_values(s.get("live")),
"token": token,
"tools": tools,
}
class SimEngine:
"""带锁的快照引擎:单例供 API 与 MQTT tick 共用"""
def __init__(self):
self._lock = threading.RLock()
self._snap = init_snapshot()
def snapshot(self):
with self._lock:
return dict(self._snap)
def tick(self):
with self._lock:
self._snap = next_snapshot(self._snap)
return dict(self._snap)
sim_engine = SimEngine()
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""JSON 持久化:设置 / 播放列表 / URL 媒体(对齐原 Rust storage 语义)"""
import json
import threading
import time
from pathlib import Path
from .config import settings
DEFAULT_SETTINGS = {
"username": "admin",
"password": "123456",
"volume": 80,
"sfx_volume": 60,
"auto_play": True,
"play_mode": "sequential",
"image_duration": 5,
"fullscreen": True,
"autostart": False,
}
class Storage:
def __init__(self, data_file: Path):
self.data_file = data_file
self._lock = threading.RLock()
self._data = self._load()
# ---------- 底层 ----------
def _load(self):
if self.data_file.exists():
try:
data = json.loads(self.data_file.read_text("utf-8"))
# 规范化:URL 来源自动标记(兼容旧数据 source=None 的 URL 条目)
for it in data.setdefault("playlist", []):
p = it.get("path", "")
if p.startswith(("http://", "https://")) and not it.get("source"):
it["source"] = "url"
return data
except Exception:
pass
return {"settings": dict(DEFAULT_SETTINGS), "playlist": [], "url_media": []}
def _save(self):
self.data_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.data_file.with_suffix(".tmp")
tmp.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), "utf-8")
tmp.replace(self.data_file)
# ---------- 设置 ----------
def get_settings(self):
with self._lock:
s = dict(DEFAULT_SETTINGS)
s.update(self._data.get("settings", {}))
return s
def update_settings(self, **kw):
with self._lock:
s = dict(DEFAULT_SETTINGS)
s.update(self._data.get("settings", {}))
for k, v in kw.items():
if v is not None and k in s:
s[k] = v
self._data["settings"] = s
self._save()
# ---------- 播放列表 ----------
def get_playlist(self):
with self._lock:
return list(self._data.get("playlist", []))
def add_to_playlist(self, path):
with self._lock:
items = self._data.setdefault("playlist", [])
if not any(it.get("path") == path for it in items):
# URL 来源自动标记,避免被当作本地文件过滤
source = "url" if path.startswith(("http://", "https://")) else None
items.append({"path": path, "source": source, "name": None})
self._save()
def remove_from_playlist(self, path):
with self._lock:
self._data.setdefault("playlist", [])
self._data["playlist"] = [it for it in self._data["playlist"] if it.get("path") != path]
self._save()
# ---------- URL 媒体 ----------
def get_url_media(self):
with self._lock:
return list(self._data.get("url_media", []))
def add_url_media(self, url, name, media_type):
"""返回 True 表示新增,False 表示已存在(重复)"""
with self._lock:
items = self._data.setdefault("url_media", [])
if any(it.get("url") == url for it in items):
return False
items.append({
"url": url, "name": name, "type": media_type,
"added_at": time.strftime("%Y-%m-%d %H:%M:%S"),
})
self._save()
return True
def delete_media(self, path):
with self._lock:
self._data.setdefault("url_media", [])
self._data["url_media"] = [it for it in self._data["url_media"] if it.get("url") != path]
self._data.setdefault("playlist", [])
self._data["playlist"] = [it for it in self._data["playlist"] if it.get("path") != path]
self._save()
storage = Storage(settings.DATA_FILE)
+17 -5
View File
@@ -1,8 +1,9 @@
"""server-core 统一入口(dispatcher)—— 对外生产服务 opc.pinesound.cn 单入口。
同一端口服务平台应用与培训子应用,按路径路由
/api/*、/uploads/*、/SpXvScDiDT.txt培训子应用(app.training,保留 /api 路由与独立 data/opc.db
其余(/auth /opc /admin /agents 等)→ 平台应用(app.main,身份 + 业务域
按「端口专属前缀」把请求分流到对应子应用,每端口自带前缀子路由、互不冲突
/park/* 园区子应用(app.park,大屏/MQTT/园区数据
/api/*、/uploads/*、/SpXvScDiDT.txt → 培训子应用(app.training,独立 data/opc.db
/auth /opc /admin /government /investor …→ 平台应用(app.main,身份 + 业务域,各端口子路由)
启动(server-core 目录):
uv run uvicorn dispatcher:app --host 0.0.0.0 --port 8090 --reload
@@ -12,11 +13,22 @@ from __future__ import annotations
import logging
from app.main import app as core_app
from app.park.app import app as park_app
from app.training.main import app as training_app
logger = logging.getLogger(__name__)
ROUTE_TRAINING_PREFIXES = ("/api", "/uploads", "/SpXvScDiDT.txt")
ROUTE_PARK_PREFIX = "/park"
def _prefix_app(path: str):
"""按路径前缀匹配目标子应用;未命中返回 None 交由平台应用(app.main)。"""
if path.startswith(ROUTE_PARK_PREFIX):
return park_app
if path.startswith(ROUTE_TRAINING_PREFIXES):
return training_app
return None
class Dispatcher:
@@ -56,8 +68,8 @@ class Dispatcher:
await self._run_lifespan(receive, send)
return
path = scope.get("path", "")
target = training_app if path.startswith(ROUTE_TRAINING_PREFIXES) else core_app
target = _prefix_app(path) or core_app
await target(scope, receive, send)
app = Dispatcher([core_app, training_app])
app = Dispatcher([core_app, training_app, park_app])
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
"pyjwt>=2.8.0",
"httpx>=0.27.0",
"python-multipart>=0.0.12",
"paho-mqtt>=1.6,<2",
]
[dependency-groups]
Generated
+8
View File
@@ -799,6 +799,12 @@ wheels = [
{ url = "https://mirrors.cloud.tencent.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" },
]
[[package]]
name = "paho-mqtt"
version = "1.6.1"
source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple/" }
sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/f8/dd/4b75dcba025f8647bc9862ac17299e0d7d12d3beadbf026d8c8d74215c12/paho-mqtt-1.6.1.tar.gz", hash = "sha256:2a8291c81623aec00372b5a85558a372c747cbca8e9934dfe218638b8eefc26f" }
[[package]]
name = "pineagents-demo-server"
version = "0.1.0"
@@ -809,6 +815,7 @@ dependencies = [
{ name = "asyncmy" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "paho-mqtt" },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "python-multipart" },
@@ -831,6 +838,7 @@ requires-dist = [
{ name = "asyncmy", specifier = ">=0.2.9" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "paho-mqtt", specifier = ">=1.6,<2" },
{ name = "pydantic", specifier = ">=2.7.0" },
{ name = "pyjwt", specifier = ">=2.8.0" },
{ name = "python-multipart", specifier = ">=0.0.12" },