Files
server-core/app/park/tenants.py
T
Pine 5f0acfa79e feat(park): 视频识别剥离,per-tenant 配置接入 park-vision 独立服务
- 删除 vision_yolo/vision_llm 及路由 /api/vision/frame|llm(保留遥测 event),去除 ultralytics 依赖
- 视频识别改为每园区(per-tenant)配置,经 park_settings(kv) 键 vision_api:<tid> 存取
- /park/api/vision/config GET/PUT(园区端读写本园区识别地址,可局域网);bind 下发含 vision_api
- 新增 alembic 0004(tenant_id 可空) / 0005(park_settings 表),迁移脚本执行
2026-08-25 01:52:23 +08:00

566 lines
22 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 -*-
"""园区租户(多租户)数据层 —— 唯一总库(SQLAlchemy asyncconfig.DATABASE_URL)。
表:park_tenants / park_companies / park_kb_docs / park_screensORM 见 app/infrastructure/models.py)。
建表/种子由 alembic + scripts/db/seed.py 在非运行态完成;本层仅读写。运行时不建表不灌种子。
sim_engine 需同步 tick,故另提供同步只读访问器 get_tenant_data_sync。
"""
from __future__ import annotations
from datetime import datetime
import hashlib
import json
import secrets
import time
import uuid
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ..config import DATABASE_URL
from ..infrastructure.db import make_async_engine
from ..infrastructure.models import ParkCompany, ParkKbDoc, ParkScreen, ParkSetting, ParkTenant
from .sim_engine import DEFAULT_DATA, refresh_engine
_DEFAULT_TENANT_ID = "T001"
# 单一异步引擎(进程内共享,避免每操作重建);建表/种子不走这里
_ASYNC_ENGINE = None
def _get_session() -> AsyncSession:
global _ASYNC_ENGINE
if _ASYNC_ENGINE is None:
_ASYNC_ENGINE = make_async_engine(DATABASE_URL)
return async_sessionmaker(bind=_ASYNC_ENGINE, expire_on_commit=False)()
def _hash_password(password: str, salt: str | None = None) -> tuple[str, str]:
salt = salt or secrets.token_hex(16)
h = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000).hex()
return salt, h
def _verify_password(password: str, salt: str, expected: str) -> bool:
return _hash_password(password, salt)[1] == expected
def _now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _new_id(prefix: str) -> str:
return f"{prefix}{int(uuid.uuid4().int % 1000000000):09d}"
# ---------------- 同步只读(sim_engine 同步 tick 线程用) ----------------
def get_tenant_data_sync(tenant_id: str) -> dict:
"""同步读取该租户 data + companiessim_engine 同步 tick,无事件循环时用)。"""
from sqlalchemy import create_engine, text
url = DATABASE_URL.replace("+aiosqlite", "")
engine = create_engine(url, future=True)
try:
with engine.connect() as conn:
row = conn.execute(text("SELECT data_json FROM park_tenants WHERE id=:id"), {"id": tenant_id}).fetchone()
data = json.loads(row[0] if row and row[0] else "{}")
comps = [_rd(r) for r in conn.execute(
text("SELECT * FROM park_companies WHERE tenant_id=:tid ORDER BY created_at DESC"), {"tid": tenant_id})]
if comps:
data = dict(data)
data["companies"] = comps
return data or dict(DEFAULT_DATA)
finally:
engine.dispose()
def _sync_conn():
from sqlalchemy import create_engine
return create_engine(DATABASE_URL.replace("+aiosqlite", ""), future=True)
def list_tenants_sync() -> list[dict]:
from sqlalchemy import text
engine = _sync_conn()
try:
with engine.connect() as conn:
rows = conn.execute(text("SELECT * FROM park_tenants ORDER BY created_at DESC")).fetchall()
return [_to_tenant(r) for r in rows]
finally:
engine.dispose()
def list_companies_sync(tenant_id: str) -> list[dict]:
from sqlalchemy import text
engine = _sync_conn()
try:
with engine.connect() as conn:
return [_rd(r) for r in conn.execute(
text("SELECT * FROM park_companies WHERE tenant_id=:tid ORDER BY created_at DESC"), {"tid": tenant_id})]
finally:
engine.dispose()
def get_agent_sync(tenant_id: str) -> dict:
from sqlalchemy import text
engine = _sync_conn()
try:
with engine.connect() as conn:
row = conn.execute(text("SELECT agent_json FROM park_tenants WHERE id=:id"), {"id": tenant_id}).fetchone()
return json.loads(row[0]) if row and row[0] else {}
finally:
engine.dispose()
# ---------------- 映射 ----------------
def _val(row, key):
if isinstance(row, dict):
return row.get(key)
if hasattr(row, key):
return getattr(row, key)
return row[key]
def _rd(row) -> dict:
"""sqlalchemy Row → dict(兼容 Row/RowMapping)。"""
if isinstance(row, dict):
return row
return dict(row._mapping) if hasattr(row, "_mapping") else {k: row[k] for k in row.keys()}
def _to_tenant(row) -> dict:
return {"id": _val(row, "id"), "name": _val(row, "name"),
"intro": json.loads(_val(row, "intro_json") or "[]"),
"auth": {"username": _val(row, "username"), "salt": _val(row, "salt") or "", "password_hash": _val(row, "password_hash") or ""},
"admin_username": _val(row, "admin_username") or "", "status": _val(row, "status") or "active",
"data": json.loads(_val(row, "data_json") or "{}"), "agent": json.loads(_val(row, "agent_json") or "{}")}
def _co(r) -> dict:
return {"id": r.id, "name": r.name, "zone": r.zone, "room": r.room, "industry": r.industry, "bio": r.bio,
"founder": r.founder, "status": r.status, "employees": r.employees, "created_at": r.created_at}
def _kb(r) -> dict:
return {"id": r.id, "grp": r.grp, "title": r.title, "content_md": r.content_md, "created_at": r.created_at}
def _sc(r) -> dict:
return {"id": r.id, "tenant_id": r.tenant_id, "device_id": r.device_id, "name": r.name, "role": r.role,
"location": r.location, "status": r.status, "code": r.code, "created_at": r.created_at}
def _summary(tid: str, name: str, intro: list, username: str, admin_username: str = "", status: str = "active") -> dict:
return {"id": tid, "name": name, "intro": intro, "username": username, "admin_username": admin_username, "status": status}
# ---------------- 租户 ----------------
async def list_tenants() -> list[dict]:
async with _get_session() as s:
rows = (await s.execute(select(ParkTenant).order_by(ParkTenant.created_at.desc()))).scalars().all()
return [_to_tenant(r) for r in rows]
async def get_tenant(tenant_id: str) -> dict | None:
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
return _to_tenant(row) if row else None
async def get_tenant_data(tenant_id: str) -> dict:
data = (await get_tenant(tenant_id) or {}).get("data", {})
comps = await list_companies(tenant_id)
if comps:
data = dict(data)
data["companies"] = comps
return data or dict(DEFAULT_DATA)
async def create_tenant(name: str, intro: list[str], username: str = "", password: str = "", tenant_id: str | None = None) -> dict:
tid = tenant_id or _new_id("T")
data = dict(DEFAULT_DATA)
async with _get_session() as s:
s.add(ParkTenant(id=tid, name=name, intro_json=json.dumps(intro or ["", ""], ensure_ascii=False),
username=username, salt="", password_hash="", admin_username="", status="active",
data_json=json.dumps(data, ensure_ascii=False), agent_json="{}", created_at=_now()))
await s.commit()
await _append_companies(s, tid, DEFAULT_DATA["companies"])
await s.commit()
refresh_engine(tid, get_tenant_data_sync(tid))
return _summary(tid, name, intro, username)
async def _append_companies(s, tenant_id: str, companies: list) -> None:
for c in companies:
nm = c["name"] if isinstance(c, dict) else c
exists = (await s.execute(select(ParkCompany).where(ParkCompany.tenant_id == tenant_id, ParkCompany.name == nm).limit(1))).scalars().first()
if exists:
continue
s.add(ParkCompany(id=_new_id("PC"), tenant_id=tenant_id, name=nm,
zone=c.get("zone", "") if isinstance(c, dict) else "", room=c.get("room", "") if isinstance(c, dict) else "",
industry=c.get("industry", "") if isinstance(c, dict) else "", bio=c.get("bio", "") if isinstance(c, dict) else "",
founder=c.get("founder", "") if isinstance(c, dict) else "", status="active", employees=None, created_at=_now()))
async def bind_admin(tenant_id: str, username: str) -> bool:
async with _get_session() as s:
r = await s.execute(update(ParkTenant).where(ParkTenant.id == tenant_id).values(admin_username=username.strip()))
await s.commit()
return r.rowcount > 0
async def unbind_admin(tenant_id: str) -> bool:
return await bind_admin(tenant_id, "")
async def find_by_admin(username: str) -> dict | None:
async with _get_session() as s:
row = (await s.execute(select(ParkTenant).where(ParkTenant.admin_username == username.strip()).limit(1))).scalars().first()
return _to_tenant(row) if row else None
async def update_tenant(tenant_id: str, patch: dict) -> dict | None:
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
if row is None:
return None
if patch.get("name") is not None:
row.name = patch["name"]
if patch.get("intro") is not None:
row.intro_json = json.dumps(patch["intro"], ensure_ascii=False)
if patch.get("data") is not None:
row.data_json = json.dumps(patch["data"], ensure_ascii=False)
if patch.get("agent") is not None:
row.agent_json = json.dumps(patch["agent"], ensure_ascii=False)
if patch.get("username") is not None:
row.username = patch["username"]
if patch.get("password"):
salt, h = _hash_password(patch["password"])
row.salt, row.password_hash = salt, h
await s.commit()
await s.refresh(row)
if patch.get("data"):
refresh_engine(tenant_id, json.loads(row.data_json))
return _summary(tenant_id, row.name, json.loads(row.intro_json or "[]"), row.username, row.admin_username or "", row.status or "active")
async def delete_tenant(tenant_id: str) -> bool:
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
if row is None:
return False
await s.delete(row) # 级联删除 companies/kb/screensFK CASCADE
await s.commit()
from .sim_engine import _engines
_engines.pop(tenant_id, None)
return True
async def set_status(tenant_id: str, status: str) -> bool:
if status not in ("active", "disabled"):
return False
async with _get_session() as s:
r = await s.execute(update(ParkTenant).where(ParkTenant.id == tenant_id).values(status=status))
await s.commit()
return r.rowcount > 0
async def ensure_default_tenant() -> str:
t = await get_tenant(_DEFAULT_TENANT_ID)
if t is None:
await create_tenant("昆明市大学生创业园", ["云南省首家政府主办大学生创业孵化园区", "空间 + 孵化 + 融资 + 政策 + AI 赋能 + 综合服务"], "admin", tenant_id=_DEFAULT_TENANT_ID)
elif not await list_companies(_DEFAULT_TENANT_ID):
async with _get_session() as s:
await _append_companies(s, _DEFAULT_TENANT_ID, DEFAULT_DATA["companies"])
await s.commit()
return _DEFAULT_TENANT_ID
# ---------------- 入驻企业 ----------------
async def list_companies(tenant_id: str) -> list[dict]:
async with _get_session() as s:
rows = (await s.execute(select(ParkCompany).where(ParkCompany.tenant_id == tenant_id).order_by(ParkCompany.created_at.desc()))).scalars().all()
return [_co(r) for r in rows]
async def create_company(tenant_id: str, payload: dict) -> dict:
row = ParkCompany(id=_new_id("PC"), tenant_id=tenant_id, name=payload.get("name", ""),
zone=payload.get("zone", ""), room=payload.get("room", ""), industry=payload.get("industry", ""),
bio=payload.get("bio", ""), founder=payload.get("founder", ""),
status=payload.get("status", "applying"), employees=payload.get("employees"), created_at=_now())
async with _get_session() as s:
s.add(row)
await s.commit()
refresh_engine(tenant_id, get_tenant_data_sync(tenant_id))
return _co(row)
async def update_company(tenant_id: str, cid: str, payload: dict) -> dict | None:
fields = {k: v for k, v in payload.items() if v is not None and k in ("name", "zone", "room", "industry", "bio", "founder", "status", "employees")}
if not fields:
return None
async with _get_session() as s:
row = (await s.execute(select(ParkCompany).where(ParkCompany.id == cid, ParkCompany.tenant_id == tenant_id))).scalars().first()
if row is None:
return None
for k, v in fields.items():
setattr(row, k, v)
await s.commit()
await s.refresh(row)
refresh_engine(tenant_id, get_tenant_data_sync(tenant_id))
return _co(row)
async def delete_company(tenant_id: str, cid: str) -> bool:
async with _get_session() as s:
r = await s.execute(delete(ParkCompany).where(ParkCompany.id == cid, ParkCompany.tenant_id == tenant_id))
await s.commit()
ok = r.rowcount > 0
refresh_engine(tenant_id, get_tenant_data_sync(tenant_id))
return ok
# ---------------- 智能体 ----------------
async def get_agent(tenant_id: str) -> dict:
return (await get_tenant(tenant_id) or {}).get("agent", {})
async def update_agent(tenant_id: str, payload: dict) -> dict:
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
agent = json.loads(row.agent_json or "{}")
for k, v in payload.items():
if v is not None:
agent[k] = v
row.agent_json = json.dumps(agent, ensure_ascii=False)
await s.commit()
return agent
# ---------------- 知识库 ----------------
async def kb_docs(tenant_id: str) -> list[dict]:
async with _get_session() as s:
rows = (await s.execute(select(ParkKbDoc).where(ParkKbDoc.tenant_id == tenant_id).order_by(ParkKbDoc.created_at.desc()))).scalars().all()
return [_kb(r) for r in rows]
async def create_kb_doc(tenant_id: str, payload: dict) -> dict:
row = ParkKbDoc(id=_new_id("KB"), tenant_id=tenant_id, grp=payload.get("group", "general"),
title=payload.get("title", ""), content_md=payload.get("content_md", ""), created_at=_now())
async with _get_session() as s:
s.add(row)
await s.commit()
return _kb(row)
async def update_kb_doc(tenant_id: str, did: str, payload: dict) -> dict | None:
async with _get_session() as s:
row = (await s.execute(select(ParkKbDoc).where(ParkKbDoc.id == did, ParkKbDoc.tenant_id == tenant_id))).scalars().first()
if row is None:
return None
for k, v in payload.items():
if v is not None and k in ("grp", "title", "content_md"):
setattr(row, k, v)
await s.commit()
await s.refresh(row)
return _kb(row)
async def delete_kb_doc(tenant_id: str, did: str) -> bool:
async with _get_session() as s:
r = await s.execute(delete(ParkKbDoc).where(ParkKbDoc.id == did, ParkKbDoc.tenant_id == tenant_id))
await s.commit()
return r.rowcount > 0
async def get_kb_doc(tenant_id: str, did: str) -> dict | None:
async with _get_session() as s:
row = (await s.execute(select(ParkKbDoc).where(ParkKbDoc.id == did, ParkKbDoc.tenant_id == tenant_id))).scalars().first()
return _kb(row) if row else None
# ---------------- 屏幕 ----------------
async def list_screens(tenant_id: str) -> list[dict]:
async with _get_session() as s:
rows = (await s.execute(select(ParkScreen).where(ParkScreen.tenant_id == tenant_id).order_by(ParkScreen.created_at.desc()))).scalars().all()
return [_sc(r) for r in rows]
async def create_screen(tenant_id: str, payload: dict) -> dict:
row = ParkScreen(id=_new_id("SCR"), tenant_id=tenant_id, device_id=payload.get("device_id", ""),
name=payload.get("name", ""), role=payload.get("role", "main"),
location=payload.get("location", ""), created_at=_now())
async with _get_session() as s:
s.add(row)
await s.commit()
return _sc(row)
async def delete_screen(tenant_id: str, sid: str) -> bool:
async with _get_session() as s:
r = await s.execute(delete(ParkScreen).where(ParkScreen.id == sid, ParkScreen.tenant_id == tenant_id))
await s.commit()
return r.rowcount > 0
# ---------------- 设备绑定(大屏 → 园区) ----------------
async def get_device_by_id(device_id: str) -> dict | None:
async with _get_session() as s:
row = (await s.execute(select(ParkScreen).where(ParkScreen.device_id == device_id).limit(1))).scalars().first()
return _sc(row) if row else None
async def ensure_device(device_id: str) -> dict:
"""确保存在该设备记录(未绑定)。"""
existing = await get_device_by_id(device_id)
if existing:
return existing
async with _get_session() as s:
s.add(ParkScreen(id=_new_id("SCR"), device_id=device_id, status="unbound", tenant_id=None,
name="", role="main", location="", created_at=_now()))
await s.commit()
return await get_device_by_id(device_id)
async def set_device_code(device_id: str, code: str, expires: str) -> dict:
async with _get_session() as s:
row = (await s.execute(select(ParkScreen).where(ParkScreen.device_id == device_id).limit(1))).scalars().first()
if row is None:
return None
row.code = code
row.code_expires = expires
row.status = "unbound"
row.tenant_id = None
await s.commit()
await s.refresh(row)
return _sc(row)
async def find_device_by_code(code: str) -> dict | None:
async with _get_session() as s:
row = (await s.execute(select(ParkScreen).where(ParkScreen.code == code, ParkScreen.status == "unbound").limit(1))).scalars().first()
if row is None:
return None
# 连接码轮换/过期校验:过期码不可再绑定(保证当前展示的最新码才有效)
if row.code_expires:
try:
if datetime.fromisoformat(row.code_expires) < datetime.now():
return None
except ValueError:
return None
return _sc(row)
async def bind_device(tenant_id: str, device_id: str, name: str = "", role: str = "main", location: str = "") -> dict | None:
async with _get_session() as s:
row = (await s.execute(select(ParkScreen).where(ParkScreen.device_id == device_id).limit(1))).scalars().first()
if row is None:
return None
row.tenant_id = tenant_id
row.status = "bound"
row.name = name or row.name or "未命名屏"
row.role = role
row.location = location
row.bound_at = _now()
row.code = ""
row.code_expires = ""
await s.commit()
await s.refresh(row)
return _sc(row)
async def unbind_device(device_id: str) -> bool:
async with _get_session() as s:
row = (await s.execute(select(ParkScreen).where(ParkScreen.device_id == device_id).limit(1))).scalars().first()
if row is None:
return False
row.tenant_id = None
row.status = "unbound"
row.code = ""
row.bound_at = ""
await s.commit()
return True
# ---------------- 大屏数据 ----------------
async def screen_view(tenant_id: str) -> dict:
t = await get_tenant(tenant_id) or {}
d = t.get("data", {})
park = d.get("park", {}) or {}
return {"tenant_id": tenant_id, "name": t.get("name", ""), "intro": t.get("intro", []),
"founded": park.get("founded"), "province_level": park.get("province_level") or park.get("provinceLevel"), "area": park.get("area"),
"region": park.get("region", ""), "address": park.get("address"), "phone": park.get("phone"), "email": park.get("email"),
"capacity": d.get("projects", {}).get("capacity"), "invested": d.get("projects", {}).get("invested"),
"jobs": d.get("jobs", {}).get("total"), "revenue_total": d.get("revenue", {}).get("total"), "revenue_tax": d.get("revenue", {}).get("tax"),
"zones": d.get("zones", []), "industryMix": d.get("industryMix", []), "feed": d.get("feed", []), "as_of": d.get("as_of", "")}
async def update_screen(tenant_id: str, payload: dict) -> dict:
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
d = dict(json.loads(row.data_json or "{}"))
park = dict(d.get("park", {}))
for k in ("founded", "area", "address", "phone", "email", "region"):
if payload.get(k) is not None:
park[k] = payload[k]
d["park"] = park
proj = dict(d.get("projects", {}))
if payload.get("capacity") is not None: proj["capacity"] = payload["capacity"]
if payload.get("invested") is not None: proj["invested"] = payload["invested"]
d["projects"] = proj
jobs = dict(d.get("jobs", {}))
if payload.get("jobs") is not None: jobs["total"] = payload["jobs"]
d["jobs"] = jobs
rev = dict(d.get("revenue", {}))
if payload.get("revenue_total") is not None: rev["total"] = payload["revenue_total"]
if payload.get("revenue_tax") is not None: rev["tax"] = payload["revenue_tax"]
d["revenue"] = rev
for k in ("feed", "zones", "industryMix"):
if payload.get(k) is not None: d[k] = payload[k]
row.name = payload.get("name") or row.name
row.intro_json = json.dumps(payload.get("intro") or json.loads(row.intro_json or "[]"), ensure_ascii=False)
row.data_json = json.dumps(d, ensure_ascii=False)
await s.commit()
await s.refresh(row)
refresh_engine(tenant_id, d)
return await screen_view(tenant_id)
# ---------------- 全局配置(park_settings kv ----------------
async def get_setting(key: str, default: str = "") -> str:
async with _get_session() as s:
row = await s.get(ParkSetting, key)
return row.value if row else default
async def set_setting(key: str, value: str) -> None:
async with _get_session() as s:
row = await s.get(ParkSetting, key)
if row is None:
s.add(ParkSetting(key=key, value=value, updated_at=_now()))
else:
row.value = value
row.updated_at = _now()
await s.commit()
# ---------------- 视频识别(per-tenant,园区端控制,存 park_settings 命名键) ----------------
async def get_tenant_vision(tenant_id: str) -> str:
"""本园区的视频识别服务地址(空=禁用)。"""
return await get_setting(f"vision_api:{tenant_id}", "")
async def set_tenant_vision(tenant_id: str, api: str) -> None:
await set_setting(f"vision_api:{tenant_id}", (api or "").strip().rstrip("/"))