Files
DPM/backend/app/routers.py
T
Pine 18d28be943 feat: 添加后端部署和API基本检索的Docker Compose配置
- 引入新的`docker-compose.yml`文件,便于后端和MQTT代理(EMQX)的部署。
- 更新多个组件中的API基本检索,使用函数进行动态解析。
- 加强了多个组件中API调用的错误处理和日志记录。
- 优化了AI聊天面板离线场景的回退响应。
- 更新DataScreen和MediaScreen组件中的数据显示和统计,以反映准确的指标。
- 重构MQTT连接逻辑,以支持动态凭证和客户端ID。
2026-08-18 06:50:50 +08:00

596 lines
20 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 -*-
"""全部 REST 路由 —— 数据 / 媒体 / 播放列表 / 设置 / AI / 展示控制"""
import asyncio
import json
import logging
import time
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from .ai_tools import run_chat, run_tools
from .asr import transcribe as asr_transcribe
from .config import settings
from .event_bus import bus
from .llm import run_chat as llm_run_chat
from .mqtt import hub
from .sim_engine import sim_engine
from .storage import storage
log = logging.getLogger("dpm.api")
router = APIRouter()
ALLOWED_EXT = {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"}
def _media_type(path):
lower = path.lower()
if lower.endswith((".mp4", ".mkv", ".avi")):
return "video"
return "image"
# ==================== 数据模型 ====================
class SettingsBody(BaseModel):
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 ActionBody(BaseModel):
action: str
class PathBody(BaseModel):
path: str
class UrlBody(BaseModel):
url: str
class StatePayload(BaseModel):
status: str = ""
index: int = 0
name: str = ""
media_type: str = ""
class StateBody(BaseModel):
state: StatePayload
class DisplayCommandBody(BaseModel):
action: str
params: dict = Field(default_factory=dict)
class ChatMessage(BaseModel):
role: str
content: str
class ChatBody(BaseModel):
messages: list[ChatMessage]
# ==================== 认证 / 设置 ====================
@router.get("/api/health")
async def health():
"""健康检查(Docker healthcheck / 运维探活)"""
return {
"ok": True,
"service": "dpm-backend",
"mqtt": hub.connected if getattr(hub, "connected", None) is not None else False,
"ts": __import__("time").time(),
}
@router.post("/api/login")
async def login(request: Request):
form = await request.form()
s = storage.get_settings()
return JSONResponse({"success": form.get("username") == s["username"] and form.get("password") == s["password"]})
@router.get("/api/settings")
async def get_settings():
s = storage.get_settings()
return {k: s[k] for k in ("volume", "play_mode", "image_duration", "fullscreen", "autostart")}
@router.get("/api/config")
async def runtime_config():
"""运行配置(供前端启动引导覆盖):MQTT 地址/账号、API 基址
—— 打包部署时展播端从后端拉取,避免构建期写死的局域网 IP 失效
"""
ws = settings.MQTT_WS_URL
# 从 ws://host:port/mqtt 中提取 broker 主机
host = "127.0.0.1"
try:
host = ws.split("://", 1)[1].split(":", 1)[0]
except Exception: # noqa: BLE001
pass
return {
"ok": True,
"mqtt_url": ws,
"mqtt_username": settings.MQTT_USERNAME or "",
"mqtt_password": settings.MQTT_PASSWORD or "",
"api_base": f"http://{host}:{settings.PORT}",
"broker_host": host,
}
@router.post("/api/settings")
async def update_settings(body: SettingsBody):
storage.update_settings(**body.model_dump(exclude_none=True))
s = storage.get_settings()
hub.publish_command("settings_changed", {"volume": s["volume"], "play_mode": s["play_mode"]})
return {"ok": True}
# ==================== 媒体 ====================
def _list_media():
files = []
media_dir = Path(settings.MEDIA_DIR)
if media_dir.exists():
for p in sorted(media_dir.iterdir()):
if not p.is_file() or p.suffix.lower() not in ALLOWED_EXT:
continue
files.append({
"name": p.name, "relative_path": p.name,
"type": _media_type(p.name), "url": f"/file/{p.name}", "source": "local",
})
for item in storage.get_url_media():
files.append({
"name": item.get("name", item.get("url", "")),
"relative_path": item.get("url", ""),
"type": item.get("type", "image"),
"url": item.get("url", ""),
"source": "url",
})
return {"files": files}
@router.get("/media")
async def list_media():
return _list_media()
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
name = file.filename or "file"
ext = Path(name).suffix.lower()
if ext not in ALLOWED_EXT:
return JSONResponse({"ok": False, "error": "不支持的文件类型"}, status_code=400)
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
stem = Path(name).stem
new_name = f"{stem}_{time.strftime('%Y%m%d%H%M%S')}{ext}"
save_path = settings.MEDIA_DIR / new_name
with save_path.open("wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
hub.publish_command("playlist_changed")
return {"ok": True, "name": new_name}
@router.post("/api/delete")
async def delete_media(body: PathBody):
path = body.path
if not path.startswith(("http://", "https://")):
target = settings.MEDIA_DIR / path
try:
if target.exists() and target.is_file():
target.unlink()
except OSError:
pass
storage.delete_media(path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/media/add-url")
async def add_url(body: UrlBody):
url = body.url.strip()
if not url:
return {"ok": False}
name = url.rsplit("/", 1)[-1].split("?", 1)[0] or url
t = _media_type(url)
is_new = storage.add_url_media(url, name, t)
return {"ok": True, "duplicate": None if is_new else True}
# ==================== 播放列表 ====================
def _playlist_files():
files = []
media_dir = Path(settings.MEDIA_DIR)
for item in storage.get_playlist():
path = item.get("path", "")
if item.get("source") == "url":
files.append({
"name": item.get("name") or path,
"relative_path": path, "type": _media_type(path), "url": path, "source": "url",
})
else:
fp = media_dir / path
if fp.exists():
files.append({
"name": fp.name, "relative_path": path,
"type": _media_type(path), "url": f"/file/{path}", "source": "local",
})
s = storage.get_settings()
return {"files": files, "volume": s["volume"], "play_mode": s["play_mode"], "image_duration": s["image_duration"]}
@router.get("/api/playlist")
async def get_playlist():
return _playlist_files()
@router.post("/api/playlist/add")
async def add_playlist(body: PathBody):
storage.add_to_playlist(body.path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/playlist/remove")
async def remove_playlist(body: PathBody):
storage.remove_from_playlist(body.path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/playlist/play")
async def play_playlist_item(body: PathBody):
"""指定媒体立即播放:加入播放列表(如未在)→ MQTT 广播 play_target → 大屏跳转播放"""
path = body.path
storage.add_to_playlist(path)
hub.publish_command("playlist_changed")
cmd = hub.publish_command("play_target", {"path": path})
return {"ok": True, "cmd_id": cmd.get("cmd_id")}
# ==================== 播放控制 / 状态 ====================
_CONTROL_ACTIONS = {"play", "pause", "next", "prev"}
@router.post("/api/control")
async def control(body: ActionBody):
if body.action not in _CONTROL_ACTIONS:
return JSONResponse({"ok": False, "error": "action 必须是 play/pause/next/prev"}, status_code=400)
hub.publish_command(body.action)
return {"ok": True, "action": body.action}
@router.get("/api/state")
async def get_state():
return {"state": {"status": "unknown", "index": 0, "name": "", "media_type": ""}}
@router.post("/api/state")
async def update_state(body: StateBody):
hub.publish_command("state_update", body.state.model_dump())
return {"ok": True}
@router.post("/api/display-command")
async def display_command(body: ActionBody):
if body.action == "minimize":
hub.publish_command("minimize")
return {"ok": True, "action": "minimize"}
return JSONResponse({"ok": False, "error": "不支持的显示命令"}, status_code=400)
# ==================== 数据(大屏全部数据来自后端) ====================
@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()
# ==================== 园区企业 ====================
_COMPANIES = [
{"name": "云南派音人工智能科技", "zone": "加速区", "room": "A1", "color": "#4c8dff"},
{"name": "米勒克尔蓝宝石珠宝", "zone": "加速区", "room": "A2", "color": "#4c8dff"},
{"name": "中泰研学合作", "zone": "加速区", "room": "A3", "color": "#4c8dff"},
{"name": "云南宸中低空经济", "zone": "加速区", "room": "A4", "color": "#4c8dff"},
{"name": "昆明智海银高文化科技", "zone": "加速区", "room": "A5", "color": "#4c8dff"},
{"name": "AI机器人大模型训练", "zone": "加速区", "room": "A6", "color": "#4c8dff"},
{"name": "云南廷秀文旅康养", "zone": "加速区", "room": "A7", "color": "#4c8dff"},
{"name": "瀚颖AI+教育信息咨询", "zone": "加速区", "room": "A8", "color": "#4c8dff"},
{"name": "云南大学AI+创业平台", "zone": "加速区", "room": "A9", "color": "#4c8dff"},
{"name": "仰光客厅", "zone": "国际区", "room": "I1", "color": "#22d3ee"},
{"name": "云南上古绝学文化", "zone": "国际区", "room": "I2", "color": "#22d3ee"},
{"name": "中越生物医疗", "zone": "国际区", "room": "I3", "color": "#22d3ee"},
{"name": "酷享野农AI农业", "zone": "国际区", "room": "I4", "color": "#22d3ee"},
{"name": "滇缅国际设计", "zone": "国际区", "room": "I5", "color": "#22d3ee"},
{"name": "昆明舒诺生物科技", "zone": "国际区", "room": "I6", "color": "#22d3ee"},
{"name": "达岸教育管理", "zone": "国际区", "room": "I7", "color": "#22d3ee"},
{"name": "Facebook越南跨境电商", "zone": "成长区", "room": "G1", "color": "#34d399"},
{"name": "研X同行者网络", "zone": "成长区", "room": "G2", "color": "#34d399"},
{"name": "朵哈·玫瑰特色产业链", "zone": "成长区", "room": "G3", "color": "#34d399"},
{"name": "昆明云韵体育", "zone": "成长区", "room": "G4", "color": "#34d399"},
{"name": "南菌优培食用菌", "zone": "成长区", "room": "G5", "color": "#34d399"},
{"name": "五华区丽裳文化", "zone": "成长区", "room": "G6", "color": "#34d399"},
{"name": "云南星瑞航空", "zone": "成长区", "room": "G7", "color": "#34d399"},
{"name": "综合直播私域平台", "zone": "成长区", "room": "G8", "color": "#34d399"},
{"name": "园区管理办公室", "zone": "园区管理", "room": "M", "color": "#fbbf24"},
]
@router.get("/api/park/companies")
async def park_companies():
return {"companies": _COMPANIES}
@router.get("/api/park/zones")
async def park_zones():
zones = ["加速区", "国际区", "成长区"]
counts = {z: sum(1 for c in _COMPANIES if c["zone"] == z) for z in zones}
colors = {"加速区": "#4c8dff", "国际区": "#22d3ee", "成长区": "#34d399"}
return {"zones": [{"name": z, "count": counts[z], "color": colors[z]} for z in zones]}
# ==================== AI 对话(通义千问 + 工具调用) ====================
# 预设问题(围绕昆明市大学生创业园 / OPC 园区性质设计,前端从后端读取)
AI_GROUPED_QUESTIONS = [
{
"title": "入驻与流程",
"questions": [
"如何申请入驻园区?",
"园区入驻的条件有哪些?",
"入驻需要准备哪些材料?",
"入驻流程是怎样的?",
"入驻评审如何打分?",
"入驻需要多长时间?",
],
},
{
"title": "政策与扶持",
"questions": [
"园区有哪些创业政策扶持?",
"如何申请创业补贴?",
"如何申请创业担保贷款?",
"对高校毕业生有什么优惠?",
"科技成果转化有哪些支持?",
],
},
{
"title": "OPC 概念",
"questions": [
"什么是 OPC",
"OPC 创业有哪些模式?",
"OPC 适合哪些人?",
"OPC 创业者从哪里开始?",
"OPC 常用的人工智能工具有哪些?",
],
},
{
"title": "场地与服务",
"questions": [
"园区提供哪些免费办公空间?",
"园区有哪些孵化服务?",
"园区提供哪些创业辅导?",
"园区有哪些基础配套?",
"园区可以免费使用哪些资源?",
],
},
{
"title": "园区企业介绍",
"questions": [
"介绍一下园区入驻企业",
"园区有哪些 AI 科技企业?",
"园区有哪些跨境电商企业?",
"园区有哪些生物医药企业?",
"介绍一下云南派音人工智能科技",
"介绍一下米勒克尔蓝宝石珠宝",
"介绍一下研X同行者网络",
],
},
]
AI_OPC_TOOLS = [
"DeepSeek-V3", "通义千问", "ChatGPT", "豆包",
"Midjourney", "Stable Diffusion", "剪映", "Notion AI",
"WPS AI", "GitHub Copilot",
]
@router.get("/api/ai/questions")
async def ai_questions():
"""预设问题列表(围绕园区性质设计,前端右侧面板展示)"""
return {
"groups": AI_GROUPED_QUESTIONS,
"opcTools": AI_OPC_TOOLS,
}
@router.post("/api/ai/chat")
async def ai_chat(body: ChatBody):
"""大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏
返回 {reply, tools, model}tools 供请求端本地同步执行"""
messages = [m.model_dump() for m in body.messages]
result = llm_run_chat(messages)
return result
@router.post("/api/ai/asr")
async def ai_asr(file: UploadFile = File(...), format: str = "m4a"):
"""语音识别:上传录音 → 阿里云 paraformer 转写为文本"""
data = await file.read()
if not data:
return JSONResponse({"ok": False, "error": "空音频"}, status_code=400)
try:
text = asr_transcribe(data, fmt=format)
return {"ok": True, "text": text}
except Exception as e: # noqa: BLE001
log.warning("ASR 转写失败: %s", e)
return JSONResponse({"ok": False, "error": str(e)}, status_code=502)
# ==================== 展示控制(管理端 → MQTT ====================
# 全部 MQTT 前端控制命令白名单(与 docs/mqtt-commands.md 保持一致)
_VALID_DISPLAY_ACTIONS = {
# 页面
"navigate", "navigate_rel",
# 全局视觉
"vision_set",
# AI 助手页
"ai_input", "ai_preset", "ai_company", "ai_zone",
# 语音对话页
"voice_start", "voice_stop", "voice_refresh",
# 媒体
"play", "pause", "next", "prev", "set_mode", "play_target",
# 全局
"alert", "show_card", "minimize", "settings_changed", "playlist_changed",
}
@router.post("/api/display/command")
async def display_command_publish(body: DisplayCommandBody):
"""管理端/任意客户端通过 REST 发控制指令 → 后端转 MQTT 广播给所有大屏"""
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)
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
"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):
q = bus.subscribe()
async def gen():
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(q.get(), timeout=15)
yield f"data: {data}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
bus.unsubscribe(q)
return StreamingResponse(gen(), media_type="text/event-stream")
# ==================== 视觉识别事件上报(前端摄像头实时识别 → 后端日志/MQTT ====================
class VisionEventBody(BaseModel):
event: str # camera_on|camera_off|detecting|facing|triggered|silent|error|model_error
faces: int = 0
dwell_ms: int = 0
detail: str = ""
@router.post("/api/vision/event")
async def vision_event(body: VisionEventBody):
"""前端摄像头识别状态/触发上报;后端记录详细日志,triggered 时广播 alert 到全屏。"""
log.info(
"vision event=%s faces=%d dwell_ms=%d detail=%s",
body.event, body.faces, body.dwell_ms, body.detail,
)
if body.event == "triggered":
hub.publish_command("alert", {"text": "有访客正对屏幕,语音助手已主动问候", "faces": body.faces})
log.info("vision triggered -> alert 已广播(faces=%d", body.faces)
return {"ok": True}
# ==================== YOLO 人脸检测(前端抽帧 → 后端推理) ====================
class VisionFrameBody(BaseModel):
image: str = "" # JPEG base64(不含 data: 前缀)
conf: float = 0.0 # 可选:覆盖置信度阈值
@router.post("/api/vision/frame")
def vision_frame(body: VisionFrameBody):
"""接收前端抽帧 JPEG base64,后端 YOLO 推理返回人脸框(faces/boxes/latency_ms)。
普通 def 由 FastAPI 线程池执行(推理约 100-200ms),不阻塞事件循环。
"""
img_b64 = (body.image or "").strip()
if not img_b64:
log.warning("vision frame: 缺少 image")
return {"ok": False, "error": "missing image"}
from .vision_yolo import predict_base64
return predict_base64(img_b64)
# ==================== 智能体工具调用 + 知识库 ====================
class ToolsExecBody(BaseModel):
name: str = ""
args: dict = {}
@router.post("/api/tools/exec")
def tools_exec(body: ToolsExecBody):
"""执行智能体工具(LLM function calling):name + args → 结果字符串"""
name = (body.name or "").strip()
if not name:
return {"ok": False, "error": "missing tool name"}
from .tools import exec_tool
result = exec_tool(name, body.args or {})
return {"ok": True, "result": result}
@router.get("/api/kb/brief")
def kb_brief(max_chars: int = 1200):
"""园区知识摘要(前端拼进对话 instructions"""
try:
from .rag import brief
return {"ok": True, "brief": brief(max_chars=max_chars)}
except Exception as e: # noqa: BLE001
log.warning("kb brief 失败: %s", e)
return {"ok": False, "error": str(e)}
@router.get("/api/kb/retrieve")
def kb_retrieve(q: str = "", top_k: int = 3):
"""知识库检索(query → 相关段落)"""
if not q.strip():
return {"ok": False, "error": "missing q"}
try:
from .rag import retrieve
chunks = retrieve(q, top_k=top_k)
return {"ok": True, "chunks": chunks}
except Exception as e: # noqa: BLE001
log.warning("kb retrieve 失败: %s", e)
return {"ok": False, "error": str(e)}
@router.get("/api/s2s/instructions")
def s2s_instructions():
"""语音助手基础提示词(服务端统一组装:配置提示词 + 园区知识库)"""
try:
from .rag import build_instructions
return {"ok": True, "instructions": build_instructions()}
except Exception as e: # noqa: BLE001
log.warning("s2s instructions 构建失败: %s", e)
return {"ok": False, "error": str(e)}