b6315f69b0
- 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取数据返回绑定园区。
822 lines
29 KiB
Python
822 lines
29 KiB
Python
# -*- 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.timeout(3.11+ 的上下文管理器)。"""
|
||
import asyncio
|
||
return asyncio.wait_for(awaitable, timeout=seconds)
|