Files
server-core/app/park/routers.py
T
Pine b6315f69b0 feat(park): 大屏设备绑定流 — 永久设备ID+连接码+园区端绑定+MQTT下发设备token
- park_screens 加 status/code/code_expires/bound_at、tenant_id 可空(alembic 0002)。
- auth 加 create/parse_device_token {tenant_id,device_id}。
- tenants 加 ensures_device/set_device_code/find_device_by_code/bind_device/unbind_device。
- 端点:/park/api/devices/register、/park/api/devices/{device_id}/code(8位码TTL10min)、
  /park/tenants/{tid}/screens/bind-by-code(校验码→绑定→MQTT opc/display/bind/<dev> 下发 token)、
  /park/api/devices/{device_id}/unbind。_resolve_tenant 支持设备 token。
- 验证(TestClient):register→code→建园区→绑码(bound/命名)→设备token取数据返回绑定园区。
2026-08-24 20:10:46 +08:00

822 lines
29 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 -*-
"""园区子应用(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 os
import time
from pathlib import Path
from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from . import park_config, tenants
from .auth import create_device_token, create_token, parse_device_token, parse_token, require_tenant
from .config import settings
from .event_bus import bus
from .mqtt import hub
from .sim_engine import engine_data, get_engine, refresh_engine, sim_engine
from .storage import storage
logger = logging.getLogger("dpm.api")
async def _resolve_tenant(authorization: str | None, tenant_id: str | None) -> str:
"""解析目标园区:?tenant_id=admin 亮传)> 大屏 tenant-token > 设备 token > 默认园区。"""
if tenant_id:
return tenant_id
if authorization and authorization.startswith("Bearer "):
tok = authorization.split(" ", 1)[1]
try:
return parse_token(tok) # 大屏 tenant token
except HTTPException:
pass
try:
return parse_device_token(tok)["tenant_id"] # 绑定后的设备 token
except HTTPException:
pass
return await tenants.ensure_default_tenant()
log = logging.getLogger("dpm.api")
router = APIRouter()
# ==================== 园区认证 + 租户管理 ====================
class LoginBody(BaseModel):
username: str
password: str
class TenantBody(BaseModel):
name: str = "新园区"
intro: list[str] = ["", ""]
username: str = "admin"
password: str = "123456"
class TenantPatch(BaseModel):
name: str | None = None
intro: list[str] | None = None
username: str | None = None
password: str | None = None
class BindBody(BaseModel):
username: str
@router.post("/auth/login", summary="园区端登录(绑定为园区管理员的平台账号)")
async def park_login(body: LoginBody):
# 登录 = 校验平台账号密码 + 该账号已绑定为某园区管理员;成功 → 签发该园区 tenant token
from app.infrastructure.repositories import Database
db = Database()
try:
user = await db.users.get_by_username(body.username.strip())
if not user or not await db.users.verify_password(user, body.password):
return JSONResponse({"ok": False, "error": "账号或密码不正确"}, status_code=401)
t = await tenants.find_by_admin(body.username.strip())
if not t:
return JSONResponse({"ok": False, "error": "该账号未绑定任何园区管理员"}, status_code=401)
if t.get("status") == "disabled":
return JSONResponse({"ok": False, "error": "该园区已禁用,禁止登录使用"}, status_code=403)
finally:
await db.close()
return {"ok": True, "tenant_id": t["id"], "name": t["name"], "intro": t["intro"], "token": create_token(t["id"])}
@router.get("/auth/me", summary="当前园区资料(页眉)")
async def park_me(authorization: str | None = Header(default=None)):
tid = require_tenant(authorization)
t = await tenants.get_tenant(tid)
if t is None:
raise HTTPException(status_code=404, detail="园区不存在")
return {"ok": True, "tenant_id": tid, "name": t["name"], "intro": t.get("intro", [])}
# ---- 租户管理(运营端)----
@router.get("/tenants", summary="园区列表")
async def tenant_list():
return {"ok": True, "tenants": await tenants.list_tenants()}
@router.post("/tenants", summary="创建园区(名称/两段式简介/账密,seed 默认数据)")
async def tenant_create(body: TenantBody):
t = await tenants.create_tenant(body.name, body.intro, body.username, body.password)
return {"ok": True, "tenant": t}
@router.put("/tenants/{tid}", summary="更新园区(名称/简介/管理员账号/密码)")
async def tenant_update(tid: str, body: TenantPatch):
patch = {k: v for k, v in body.model_dump(exclude_none=True).items() if v is not None}
t = await tenants.update_tenant(tid, patch)
if t is None:
return JSONResponse({"ok": False, "error": "园区不存在"}, status_code=404)
refresh_engine(tid)
return {"ok": True, "tenant": t}
@router.delete("/tenants/{tid}", summary="删除园区")
async def tenant_delete(tid: str):
ok = await tenants.delete_tenant(tid)
return {"ok": ok, "id": tid}
@router.post("/tenants/{tid}/bind", summary="绑定园区管理员(已有平台账号)")
async def tenant_bind(tid: str, body: BindBody):
from app.infrastructure.repositories import Database
db = Database()
try:
user = await db.users.get_by_username(body.username.strip())
finally:
await db.close()
if user is None:
return JSONResponse({"ok": False, "error": "账号不存在"}, status_code=404)
ok = await tenants.bind_admin(tid, body.username.strip())
return {"ok": ok, "tenant_id": tid, "admin_username": body.username.strip()}
@router.post("/tenants/{tid}/unbind", summary="解除园区管理员")
async def tenant_unbind(tid: str):
ok = await tenants.unbind_admin(tid)
return {"ok": ok, "tenant_id": tid}
class StatusBody(BaseModel):
status: str # active | disabled
@router.post("/tenants/{tid}/status", summary="禁用/启用园区(不删,禁用禁止登录)")
async def tenant_status(tid: str, body: StatusBody):
ok = await tenants.set_status(tid, body.status)
if not ok:
return JSONResponse({"ok": False, "error": "无效状态或园区不存在"}, status_code=400)
return {"ok": True, "tenant_id": tid, "status": body.status}
# ==================== 数据模型 ====================
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 ActionBody(BaseModel):
action: str
class PathBody(BaseModel):
path: str
class UrlBody(BaseModel):
url: str
name: str = ""
type: str = "image"
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 = ""
class CompanyBody(BaseModel):
name: str = ""
zone: str = ""
room: str = ""
industry: str = ""
bio: str = ""
founder: str = ""
status: str = "applying"
employees: int | None = None
class AgentBody(BaseModel):
system_prompt: str | None = None
model: str | None = None
enabled_tools: list[str] | None = None
preset_questions: list[str] | None = None
opening: str | None = None
class KbDocBody(BaseModel):
group: str = "general"
title: str = ""
content_md: str = ""
class ScreenBody(BaseModel):
name: str | None = None
region: str | None = None
capacity: int | None = None
invested: int | None = None
jobs: int | None = None
area: int | None = None
founded: int | None = None
address: str | None = None
phone: str | None = None
email: str | None = None
revenue_total: float | None = None
revenue_tax: float | None = None
intro: list[str] | None = None
feed: list[dict] | None = None
zones: list[dict] | None = None
industryMix: list[dict] | None = None
# ==================== 健康 / 设置 / 引导 ====================
@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",
}
# ==================== 媒体上传 / 播放控制 / 客户端更新(迁自 park-desktop backend ====================
@router.post("/api/upload")
async def upload_media(file: UploadFile = File(...), tenant_id: str | None = None):
"""上传媒体(图片/视频)到园区媒体目录,返回 {path, url}。"""
from .config import settings
fname = Path(file.filename or "upload.bin").name
dest = settings.MEDIA_DIR / fname
dest.parent.mkdir(parents=True, exist_ok=True)
data = await file.read()
dest.write_bytes(data)
return {"ok": True, "path": f"/park/file/{fname}", "url": f"/park/file/{fname}"}
@router.get("/api/state")
async def get_state():
return {"status": "idle", "index": 0, "name": "", "media_type": ""}
@router.post("/api/control")
async def control(body: ActionBody):
cmd = hub.publish_command(body.action)
return {"ok": cmd.get("published", False)}
_INSTALLER_DIR = Path(__file__).resolve().parent.parent.parent / "serverdata" / "park" / "installers"
@router.get("/download")
async def download_installer():
"""下载 Windows 客户端安装包(serverdata/park/installers/ 下最新 *.setup.exe)。"""
files = sorted(_INSTALLER_DIR.glob("*setup.exe"), reverse=True)
if not files:
return JSONResponse({"ok": False, "error": "安装包不存在"}, status_code=404)
f = files[0]
return FileResponse(path=str(f), filename=f.name, media_type="application/octet-stream")
class UpdateCheckBody(BaseModel):
current: str = ""
@router.post("/api/update/check")
async def update_check(body: UpdateCheckBody):
"""客户端更新检测:返回最新版本号、安装包下载地址、是否需要更新。"""
files = sorted(_INSTALLER_DIR.glob("*setup.exe"), reverse=True)
if not files:
return {"ok": False, "error": "installer not found"}
install = files[0].name
import re
m = re.search(r"_(\d+\.\d+(?:\.\d+)?)", install)
latest = m.group(1) if m else ""
current = (body.current or "").strip()
update_available = bool(latest) and (not current or current != latest)
return {"ok": True, "latest_version": latest, "installer_name": install, "download_url": "/download", "update_available": update_available}
# ==================== 媒体 / 播放列表(storage → serverdata/park/data.json ====================
def _media_files() -> list[dict]:
from .config import settings
out = []
for f in sorted(settings.MEDIA_DIR.glob("*")):
if f.is_file() and f.suffix.lower() in {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"}:
out.append({"relative_path": f.name, "name": f.name,
"type": "video" if f.suffix.lower() in {".mp4", ".mkv", ".avi"} else "image",
"size": f.stat().st_size})
return out
@router.get("/media")
async def list_media():
return _media_files()
@router.get("/api/playlist")
async def get_playlist():
from .storage import storage
return {"playlist": storage.get_playlist()}
@router.post("/api/playlist/add")
async def add_playlist(body: PathBody):
from .storage import storage
storage.add_to_playlist(body.path)
hub.publish_command("playlist_changed", {"action": "add", "path": body.path})
return {"ok": True}
@router.post("/api/playlist/remove")
async def remove_playlist(body: PathBody):
from .storage import storage
storage.remove_from_playlist(body.path)
hub.publish_command("playlist_changed", {"action": "remove", "path": body.path})
return {"ok": True}
@router.post("/api/playlist/play")
async def play_playlist(body: PathBody):
hub.publish_command("play_target", {"path": body.path})
return {"ok": True}
@router.post("/api/media/add-url")
async def add_url(body: UrlBody):
from .storage import storage
created = storage.add_url_media(body.url, body.name, body.type)
if not created:
return {"ok": False, "error": "已存在"}
return {"ok": True}
@router.post("/api/delete")
async def delete_media(body: PathBody):
from .storage import storage
storage.delete_media(body.path)
return {"ok": True}
@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 "",
# 大屏前端 request() 以 `${api_base}/api/...`、`${api_base}/file/...` 拼接,
# 故 api_base 返回 `/park` 前缀(不含 /api),避免 /api 双写。
"media_base": f"{request.base_url}park/file",
"api_base": f"{request.base_url}park",
}
# ==================== 大屏数据(sim_engine ====================
@router.get("/api/dashboard/snapshot")
async def dashboard_snapshot(authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return get_engine(tid).snapshot()
@router.get("/api/dashboard/overview")
async def dashboard_overview(authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return get_engine(tid).snapshot()
@router.get("/api/park/zones")
async def park_zones(authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return get_engine(tid).snapshot().get("zones", [])
# ==================== 屏幕管理(园区端创建/管理大屏设备) ====================
class ScreenBody2(BaseModel):
device_id: str = ""
name: str = ""
role: str = "main"
location: str = ""
@router.get("/api/screens")
async def screens_list(authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return await tenants.list_screens(tid)
@router.post("/api/screens")
async def screens_create(body: ScreenBody2, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return await tenants.create_screen(tid, body.model_dump())
@router.delete("/api/screens/{sid}")
async def screens_delete(sid: str, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
ok = await tenants.delete_screen(tid, sid)
return {"ok": ok, "id": sid}
# ==================== 大屏设备注册 / 绑定(连接码 + MQTT 通知) ====================
class DeviceBody(BaseModel):
device_id: str = ""
class BindByCodeBody(BaseModel):
code: str
name: str = ""
role: str = "main"
location: str = ""
def _gen_code() -> str:
import random
return str(random.randint(10000000, 99999999))
def _publish_bind(device_id: str, payload: dict) -> None:
"""未绑定大屏仅订阅 bind 频道;绑定成功后经此频道通知并下发 token。"""
hub.publish(f"opc/display/bind/{device_id}", payload, qos=1)
@router.post("/api/devices/register")
async def device_register(body: DeviceBody):
device_id = (body.device_id or "").strip()
if not device_id:
return JSONResponse({"ok": False, "error": "device_id 必填"}, status_code=400)
d = await tenants.ensure_device(device_id)
return {"ok": True, "device_id": device_id, "bound": d.get("status") == "bound", "tenant_id": d.get("tenant_id")}
@router.post("/api/devices/{device_id}/code")
async def device_code(device_id: str):
d = await tenants.ensure_device(device_id)
code = _gen_code()
expires = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(time.time() + 600))
await tenants.set_device_code(device_id, code, expires)
return {"ok": True, "code": code, "expires": expires}
@router.post("/tenants/{tid}/screens/bind-by-code")
async def bind_screen_by_code(tid: str, body: BindByCodeBody):
"""园区端输入/扫码绑定:校验码 → 绑定到该园区 → 签发设备 token + MQTT 通知大屏。"""
d = await tenants.find_device_by_code(body.code.strip())
if d is None:
return JSONResponse({"ok": False, "error": "连接码无效或已过期"}, status_code=404)
dev_id = d["device_id"]
if d.get("tenant_id") and d["tenant_id"] != tid:
return JSONResponse({"ok": False, "error": "该大屏已绑定其它园区"}, status_code=409)
screen = await tenants.bind_device(tid, dev_id, body.name or "", body.role or "main", body.location or "")
token = create_device_token(tid, dev_id)
tenant = await tenants.get_tenant(tid)
_publish_bind(dev_id, {"event": "bound", "token": token, "tenant_id": tid,
"name": (tenant or {}).get("name", ""), "intro": (tenant or {}).get("intro", [])})
return {"ok": True, "screen": screen, "token": token}
@router.post("/api/devices/{device_id}/unbind")
async def device_unbind(device_id: str, authorization: str | None = Header(None), tenant_id: str | None = None):
await _resolve_tenant(authorization, tenant_id)
ok = await tenants.unbind_device(device_id)
return {"ok": ok, "device_id": device_id}
# ==================== 入驻企业(租户 data.companies 主数据源) ====================
@router.get("/api/park/companies")
async def park_companies(authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return await tenants.list_companies(tid)
@router.post("/api/park/companies")
async def park_company_create(body: CompanyBody, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return await tenants.create_company(tid, body.model_dump())
@router.put("/api/park/companies/{cid}")
async def park_company_update(cid: str, body: CompanyBody, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
row = await tenants.update_company(tid, cid, body.model_dump(exclude_unset=True))
if row is None:
return JSONResponse({"ok": False, "error": "企业不存在"}, status_code=404)
return row
@router.delete("/api/park/companies/{cid}")
async def park_company_delete(cid: str, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
ok = await tenants.delete_company(tid, cid)
return {"ok": ok, "id": cid}
# ==================== 园区智能体 / 知识库 / 大屏数据 ====================
@router.get("/api/agent/config")
async def agent_config_get(authorization: str | None = Header(None), tenant_id: str | None = None):
return await tenants.get_agent(await _resolve_tenant(authorization, tenant_id))
@router.put("/api/agent/config")
async def agent_config_put(body: AgentBody, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return await tenants.update_agent(tid, body.model_dump(exclude_none=True))
@router.get("/api/kb/docs")
async def kb_docs_list(authorization: str | None = Header(None), tenant_id: str | None = None):
return await tenants.kb_docs(await _resolve_tenant(authorization, tenant_id))
@router.post("/api/kb/docs")
async def kb_docs_create(body: KbDocBody, authorization: str | None = Header(None), tenant_id: str | None = None):
return await tenants.create_kb_doc(await _resolve_tenant(authorization, tenant_id), body.model_dump())
@router.put("/api/kb/docs/{did}")
async def kb_docs_update(did: str, body: KbDocBody, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
row = await tenants.update_kb_doc(tid, did, body.model_dump(exclude_unset=True))
if row is None:
return JSONResponse({"ok": False, "error": "文档不存在"}, status_code=404)
return row
@router.delete("/api/kb/docs/{did}")
async def kb_docs_delete(did: str, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
ok = await tenants.delete_kb_doc(tid, did)
return {"ok": ok, "id": did}
@router.post("/api/kb/reindex")
async def kb_reindex(authorization: str | None = Header(None), tenant_id: str | None = None):
# 知识库索引重建(重模块 rag 后续接入);当前仅返回 ok 占位
return {"ok": True, "reindexed": False}
@router.get("/api/screen/data")
async def screen_data_get(authorization: str | None = Header(None), tenant_id: str | None = None):
return await tenants.screen_view(await _resolve_tenant(authorization, tenant_id))
@router.put("/api/screen/data")
async def screen_data_put(body: ScreenBody, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
return await tenants.update_screen(tid, body.model_dump(exclude_none=True))
# ==================== AI / 知识库 / 语音识别 / 视觉(重模块已迁入) ====================
class ChatMessage(BaseModel):
role: str
content: str
class ChatBody(BaseModel):
messages: list[ChatMessage]
screen_id: str = ""
class ToolsExecBody(BaseModel):
name: str = ""
args: dict = {}
screen_id: str = ""
class VisionEventBody(BaseModel):
event: str
faces: int = 0
dwell_ms: int = 0
detail: str = ""
class VisionFrameBody(BaseModel):
image: str = ""
conf: float = 0.0
class VisionLlmBody(BaseModel):
image: str = ""
prompt: str = ""
AI_GROUPED_QUESTIONS = [
{"title": "入驻与流程", "questions": ["如何申请入驻园区?", "园区入驻的条件有哪些?", "入驻需要准备哪些材料?", "入驻流程是怎样的?", "入驻评审如何打分?", "入驻需要多长时间?"]},
{"title": "政策与扶持", "questions": ["园区有哪些创业政策扶持?", "如何申请创业补贴?", "如何申请创业担保贷款?", "对高校毕业生有什么优惠?", "科技成果转化有哪些支持?"]},
{"title": "OPC 概念", "questions": ["什么是 OPC", "OPC 创业有哪些模式?", "OPC 适合哪些人?", "OPC 创业者从哪里开始?", "OPC 常用的人工智能工具有哪些?"]},
{"title": "场地与服务", "questions": ["园区提供哪些免费办公空间?", "园区有哪些孵化服务?", "园区有哪些创业辅导?", "园区有哪些基础配套?", "园区可以免费使用哪些资源?"]},
{"title": "园区企业介绍", "questions": ["介绍一下园区入驻企业", "园区有哪些 AI 科技企业?", "园区有哪些跨境电商企业?", "园区有哪些生物医药企业?", "介绍一下云南派音人工智能科技"]},
]
AI_OPC_TOOLS = ["DeepSeek", "通义千问", "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):
from .llm import run_chat as llm_run_chat
messages = [m.model_dump() for m in body.messages]
return llm_run_chat(messages, body.screen_id or "")
@router.post("/api/ai/asr")
async def ai_asr(file: UploadFile = File(...), format: str = "m4a"):
"""语音识别:上传录音(表单 file)→ 阿里云 paraformer 转写为文本。"""
from .asr import transcribe as asr_transcribe
data = await file.read()
if not data:
return JSONResponse({"ok": False, "error": "空音频"}, status_code=400)
try:
return {"ok": True, "text": asr_transcribe(data, fmt=format)}
except Exception as e: # noqa: BLE001
return JSONResponse({"ok": False, "error": str(e)}, status_code=502)
@router.post("/api/tools/exec")
def tools_exec(body: ToolsExecBody):
from .tools import exec_tool
name = (body.name or "").strip()
if not name:
return {"ok": False, "error": "missing tool name"}
result = exec_tool(name, body.args or {}, body.screen_id or "")
return {"ok": True, "result": result}
@router.get("/api/kb/brief")
def kb_brief(max_chars: int = 1200):
try:
from .rag import brief
return {"ok": True, "brief": brief(max_chars=max_chars)}
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e)}
@router.get("/api/kb/retrieve")
def kb_retrieve(q: str = "", top_k: int = 3):
if not q.strip():
return {"ok": False, "error": "missing q"}
try:
from .rag import retrieve
return {"ok": True, "chunks": retrieve(q, top_k=top_k)}
except Exception as e: # noqa: BLE001
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
return {"ok": False, "error": str(e)}
@router.post("/api/vision/event")
async def vision_event(body: VisionEventBody):
log.info("vision event=%s faces=%d dwell_ms=%d detail=%s", body.event, body.faces, body.dwell_ms, body.detail)
return {"ok": True}
@router.post("/api/vision/frame")
def vision_frame(body: VisionFrameBody):
img_b64 = (body.image or "").strip()
if not img_b64:
return {"ok": False, "error": "missing image"}
from .vision_yolo import predict_base64
return predict_base64(img_b64)
@router.post("/api/vision/llm")
def vision_llm(body: VisionLlmBody):
img = (body.image or "").strip()
if not img:
return {"ok": False, "error": "missing image"}
try:
from .vision_llm import analyze_scene
return analyze_scene(img, body.prompt)
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e)}
# ==================== 展示控制(管理端 → 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, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
hub.register_device(body.device_id, body.role, body.parent_id, tid)
return {"ok": True, "device_id": body.device_id, "role": body.role, "parent_id": body.parent_id, "tenant_id": tid}
@router.post("/api/display/command")
async def display_command_publish(body: DisplayCommandBody, authorization: str | None = Header(None), tenant_id: str | None = None):
tid = tenant_id or require_tenant(authorization)
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, tid)
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",
"tenant_id": tid, "mqtt_connected": hub.connected}
@router.get("/api/display/state")
async def display_state(authorization: str | None = Header(None), tenant_id: str | None = None):
tid = await _resolve_tenant(authorization, tenant_id)
st = hub.status()
# 只返回该租户在线设备
st["devices"] = [d for d in st.get("devices", []) if _device_tenant(d) == tid]
st["screens_online"] = len(st["devices"])
st["tenant_id"] = tid
return st
def _device_tenant(device: dict) -> str:
# status() 未带 tenant_id,从 hub 设备表补齐
for cid, v in hub.devices.items():
if cid == device.get("device_id"):
return v.get("tenant_id", "")
return ""
# ==================== 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)