2026-08-24 18:06:51 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
2026-08-24 20:01:03 +08:00
|
|
|
|
"""园区租户(多租户)数据层 —— 唯一总库(SQLAlchemy async,config.DATABASE_URL)。
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
表:park_tenants / park_companies / park_kb_docs / park_screens(ORM 见 app/infrastructure/models.py)。
|
|
|
|
|
|
建表/种子由 alembic + scripts/db/seed.py 在非运行态完成;本层仅读写。运行时不建表不灌种子。
|
|
|
|
|
|
sim_engine 需同步 tick,故另提供同步只读访问器 get_tenant_data_sync。
|
2026-08-24 18:06:51 +08:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-08-25 01:52:23 +08:00
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
2026-08-24 18:06:51 +08:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
import secrets
|
|
|
|
|
|
import time
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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
|
2026-08-25 01:52:23 +08:00
|
|
|
|
from ..infrastructure.models import ParkCompany, ParkKbDoc, ParkScreen, ParkSetting, ParkTenant
|
2026-08-24 18:06:51 +08:00
|
|
|
|
from .sim_engine import DEFAULT_DATA, refresh_engine
|
|
|
|
|
|
|
|
|
|
|
|
_DEFAULT_TENANT_ID = "T001"
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
# 单一异步引擎(进程内共享,避免每操作重建);建表/种子不走这里
|
|
|
|
|
|
_ASYNC_ENGINE = None
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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)()
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
# ---------------- 同步只读(sim_engine 同步 tick 线程用) ----------------
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
def get_tenant_data_sync(tenant_id: str) -> dict:
|
|
|
|
|
|
"""同步读取该租户 data + companies(sim_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()
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
def _sync_conn():
|
|
|
|
|
|
from sqlalchemy import create_engine
|
|
|
|
|
|
return create_engine(DATABASE_URL.replace("+aiosqlite", ""), future=True)
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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()
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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()
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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()
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
# ---------------- 映射 ----------------
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
def _val(row, key):
|
|
|
|
|
|
if isinstance(row, dict):
|
|
|
|
|
|
return row.get(key)
|
|
|
|
|
|
if hasattr(row, key):
|
|
|
|
|
|
return getattr(row, key)
|
|
|
|
|
|
return row[key]
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 18:46:41 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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()}
|
2026-08-24 18:46:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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 "{}")}
|
2026-08-24 18:46:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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}
|
2026-08-24 18:46:41 +08:00
|
|
|
|
|
2026-08-24 19:05:16 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
def _kb(r) -> dict:
|
|
|
|
|
|
return {"id": r.id, "grp": r.grp, "title": r.title, "content_md": r.content_md, "created_at": r.created_at}
|
2026-08-24 19:05:16 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
|
|
|
|
|
|
def _sc(r) -> dict:
|
2026-08-24 20:10:46 +08:00
|
|
|
|
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}
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 19:05:16 +08:00
|
|
|
|
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}
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
# ---------------- 租户 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-08-24 18:06:51 +08:00
|
|
|
|
for c in companies:
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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/screens(FK CASCADE)
|
|
|
|
|
|
await s.commit()
|
|
|
|
|
|
from .sim_engine import _engines
|
|
|
|
|
|
_engines.pop(tenant_id, None)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
# ---------------- 入驻企业 ----------------
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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]
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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()
|
2026-08-24 18:06:51 +08:00
|
|
|
|
return agent
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
# ---------------- 知识库 ----------------
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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]
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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)
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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)
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
# ---------------- 屏幕 ----------------
|
2026-08-24 18:46:41 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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]
|
2026-08-24 18:46:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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)
|
2026-08-24 18:46:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
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
|
2026-08-24 18:46:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:10:46 +08:00
|
|
|
|
# ---------------- 设备绑定(大屏 → 园区) ----------------
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-08-25 01:52:23 +08:00
|
|
|
|
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)
|
2026-08-24 20:10:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
# ---------------- 大屏数据 ----------------
|
2026-08-24 18:06:51 +08:00
|
|
|
|
|
2026-08-24 20:01:03 +08:00
|
|
|
|
async def screen_view(tenant_id: str) -> dict:
|
|
|
|
|
|
t = await get_tenant(tenant_id) or {}
|
2026-08-24 18:06:51 +08:00
|
|
|
|
d = t.get("data", {})
|
|
|
|
|
|
park = d.get("park", {}) or {}
|
2026-08-24 20:01:03 +08:00
|
|
|
|
return {"tenant_id": tenant_id, "name": t.get("name", ""), "intro": t.get("intro", []),
|
2026-08-24 18:06:51 +08:00
|
|
|
|
"founded": park.get("founded"), "province_level": park.get("province_level") or park.get("provinceLevel"), "area": park.get("area"),
|
2026-08-24 18:09:23 +08:00
|
|
|
|
"region": park.get("region", ""), "address": park.get("address"), "phone": park.get("phone"), "email": park.get("email"),
|
2026-08-24 18:06:51 +08:00
|
|
|
|
"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"),
|
2026-08-24 20:01:03 +08:00
|
|
|
|
"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)
|
2026-08-25 01:52:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 全局配置(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("/"))
|