Files
server-core/app/park/tenants.py
T

566 lines
22 KiB
Python
Raw Normal View History

# -*- 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("/"))