Files
server-core/app/park/routers.py
T
Pine 81f4dfd0ad feat(park): 园区多租户 — SQLite(tenants表+companies+kb) + 每租户引擎 + 认证 + 作用域隔离
- tenants.py:park_tenants/park_companies/park_kb_docs 三表(serverdata/park/park.db),
  每园区独立 name/intro(两段式)/auth(账密 salted hash)/data(大屏锚点)/agent/kb;
  ensure_default_tenant 自愈建默认园区+灌 39 家企业。
- sim_engine 重构为 SimEngine(data)——每租户独立实例,get_engine(tid)/refresh_engine(tid)。
- auth.py:/park/auth/login(账密→长效 tenant JWT)、/park/auth/me、require_tenant(401)。
- mqtt:register/publish_command/publish_tick 按 tenant_id 作用域(拓扑为 command/tid/clientId)。
- routers:/park/tenants CRUD + 全部数据/指令端点租户作用域(authorization token 或 ?tenant_id)。
- app.py lifespan init_db + 逐租户 tick/publish;tools._query_companies 读租户库。
- .gitignore 增 serverdata/park/。TestClient:建租户/登录/快照隔离/企业CRUD/指令鉴权(无token401) 全过。
2026-08-24 18:06:51 +08:00

530 lines
19 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 time
from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from . import park_config, tenants
from .auth import create_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")
def _resolve_tenant(authorization: str | None, tenant_id: str | None) -> str:
"""解析目标园区:大屏 park-token > ?tenant_id= > 默认园区。"""
if authorization and authorization.startswith("Bearer "):
return parse_token(authorization.split(" ", 1)[1])
if tenant_id:
return tenant_id
return 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
@router.post("/auth/login", summary="大屏/园区登录")
async def park_login(body: LoginBody):
t = tenants.verify_login(body.username, body.password)
if not t:
return JSONResponse({"ok": False, "error": "账号或密码不正确"}, status_code=401)
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 = 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": tenants.list_tenants()}
@router.post("/tenants", summary="创建园区(名称/两段式简介/账密,seed 默认数据)")
async def tenant_create(body: TenantBody):
t = 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: TenantBody):
patch = {k: v for k, v in body.model_dump().items() if v is not None}
t = 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 = tenants.delete_tenant(tid)
return {"ok": ok, "id": tid}
# ==================== 数据模型 ====================
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 = ""
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
# ==================== 健康 / 设置 / 引导 ====================
@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 "",
# 大屏前端 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 = _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 = _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 = _resolve_tenant(authorization, tenant_id)
return get_engine(tid).snapshot().get("zones", [])
# ==================== 入驻企业(租户 data.companies 主数据源) ====================
@router.get("/api/park/companies")
async def park_companies(authorization: str | None = Header(None), tenant_id: str | None = None):
tid = _resolve_tenant(authorization, tenant_id)
return 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 = _resolve_tenant(authorization, tenant_id)
return 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 = _resolve_tenant(authorization, tenant_id)
row = 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 = _resolve_tenant(authorization, tenant_id)
ok = 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 tenants.get_agent(_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 = _resolve_tenant(authorization, tenant_id)
return 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 tenants.kb_docs(_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 tenants.create_kb_doc(_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 = _resolve_tenant(authorization, tenant_id)
row = 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 = _resolve_tenant(authorization, tenant_id)
ok = 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 tenants.screen_view(_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 = _resolve_tenant(authorization, tenant_id)
return 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 = _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)):
tid = 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 = _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)