Files
server-core/app/park/tenants.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

420 lines
16 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 -*-
"""园区租户(多租户)—— SQLite 持久化(serverdata/park/park.db,后续可迁 MySQL/等)。
表:
park_tenants(id, name, intro_json, username, salt, password_hash, data_json, agent_json, created_at)
park_companies(id, tenant_id, name, zone, room, industry, bio, founder, status, employees, created_at)
park_kb_docs(id, tenant_id, grp, title, content_md, created_at)
data_json(大屏锚点:as_of/token/projects/jobs/revenue/cumulative/park/zones/industryMix/feed
与 agent_json 用 JSON 列(灵活、易迁移);companies/kb 用表(可直接查询/CRUD)。
"""
from __future__ import annotations
import hashlib
import json
import secrets
import sqlite3
import time
import uuid
from pathlib import Path
from .config import PARK_DIR
from .sim_engine import DEFAULT_DATA, refresh_engine
DB_PATH = Path(PARK_DIR) / "park.db"
_DEFAULT_TENANT_ID = "T001"
SCHEMA = """
CREATE TABLE IF NOT EXISTS park_tenants (
id TEXT PRIMARY KEY,
name TEXT,
intro_json TEXT,
username TEXT,
salt TEXT,
password_hash TEXT,
data_json TEXT,
agent_json TEXT,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS park_companies (
id TEXT PRIMARY KEY,
tenant_id TEXT,
name TEXT,
zone TEXT DEFAULT '',
room TEXT DEFAULT '',
industry TEXT DEFAULT '',
bio TEXT DEFAULT '',
founder TEXT DEFAULT '',
status TEXT DEFAULT 'applying',
employees INTEGER,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS park_kb_docs (
id TEXT PRIMARY KEY,
tenant_id TEXT,
grp TEXT DEFAULT 'general',
title TEXT,
content_md TEXT DEFAULT '',
created_at TEXT
);
"""
def _conn() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
conn = _conn()
conn.executescript(SCHEMA)
conn.commit()
conn.close()
ensure_default_tenant()
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}"
# ---------------- 租户 ----------------
def _row_to_tenant(row) -> dict:
return {
"id": row["id"],
"name": row["name"],
"intro": json.loads(row["intro_json"] or "[]"),
"auth": {"username": row["username"], "salt": row["salt"], "password_hash": row["password_hash"]},
"data": json.loads(row["data_json"] or "{}"),
"agent": json.loads(row["agent_json"] or "{}"),
}
def list_tenants() -> list[dict]:
conn = _conn()
rows = conn.execute("SELECT * FROM park_tenants ORDER BY created_at DESC").fetchall()
conn.close()
return [_row_to_tenant(r) for r in rows]
def get_tenant(tenant_id: str) -> dict | None:
conn = _conn()
row = conn.execute("SELECT * FROM park_tenants WHERE id=?", (tenant_id,)).fetchone()
conn.close()
return _row_to_tenant(row) if row else None
def _get_data(tenant_id: str) -> dict:
t = get_tenant(tenant_id)
return (t or {}).get("data", {})
def get_tenant_data(tenant_id: str) -> dict:
"""给 sim_engine 用:data 锚点 + 注入 companies(来自 park_companies 表)。"""
data = _get_data(tenant_id)
comps = list_companies(tenant_id)
if comps:
data = dict(data)
data["companies"] = comps
return data or dict(DEFAULT_DATA)
def create_tenant(name: str, intro: list[str], username: str, password: str) -> dict:
salt, h = _hash_password(password)
tid = _new_id("T")
data = dict(DEFAULT_DATA)
conn = _conn()
conn.execute(
"INSERT INTO park_tenants (id,name,intro_json,username,salt,password_hash,data_json,agent_json,created_at) VALUES (?,?,?,?,?,?,?,?,?)",
(tid, name, json.dumps(intro or ["", ""]), username, salt, h, json.dumps(data, ensure_ascii=False), json.dumps({}, ensure_ascii=False), _now()),
)
conn.commit()
conn.close()
_seed_companies(tid, DEFAULT_DATA["companies"])
refresh_engine(tid)
return _summary(tid, name, intro, username)
def update_tenant(tenant_id: str, patch: dict) -> dict | None:
conn = _conn()
t = get_tenant(tenant_id)
if t is None:
conn.close()
return None
sets, args = [], []
if patch.get("name") is not None:
sets.append("name=?"); args.append(patch["name"])
if patch.get("intro") is not None:
sets.append("intro_json=?"); args.append(json.dumps(patch["intro"], ensure_ascii=False))
if patch.get("data") is not None:
sets.append("data_json=?"); args.append(json.dumps(patch["data"], ensure_ascii=False))
if patch.get("agent") is not None:
sets.append("agent_json=?"); args.append(json.dumps(patch["agent"], ensure_ascii=False))
if patch.get("username") is not None:
sets.append("username=?"); args.append(patch["username"])
if patch.get("password"):
salt, h = _hash_password(patch["password"])
sets.append("salt=?"); sets.append("password_hash=?"); args += [salt, h]
if not sets:
conn.close()
return _summary(tenant_id, t["name"], t["intro"], t["auth"]["username"])
args.append(tenant_id)
conn.execute(f"UPDATE park_tenants SET {', '.join(sets)} WHERE id=?", args)
conn.commit()
conn.close()
if patch.get("data"):
refresh_engine(tenant_id)
t2 = get_tenant(tenant_id)
return _summary(tenant_id, t2["name"], t2["intro"], t2["auth"]["username"])
def delete_tenant(tenant_id: str) -> bool:
conn = _conn()
cur = conn.execute("DELETE FROM park_tenants WHERE id=?", (tenant_id,))
conn.execute("DELETE FROM park_companies WHERE tenant_id=?", (tenant_id,))
conn.execute("DELETE FROM park_kb_docs WHERE tenant_id=?", (tenant_id,))
conn.commit()
conn.close()
refresh_engine(tenant_id) # 淘汰缓存引擎
from .sim_engine import _engines
_engines.pop(tenant_id, None)
return cur.rowcount > 0
def verify_login(username: str, password: str) -> dict | None:
conn = _conn()
row = conn.execute("SELECT * FROM park_tenants WHERE username=?", (username,)).fetchone()
conn.close()
if row and _verify_password(password, row["salt"], row["password_hash"]):
return _summary(row["id"], row["name"], json.loads(row["intro_json"] or "[]"), row["username"])
return None
def ensure_default_tenant() -> str:
"""确保至少存在一个默认园区(昆明市大学生创业园)。已有则补种企业(自愈)。"""
conn = _conn()
if conn.execute("SELECT 1 FROM park_tenants WHERE id=?", (_DEFAULT_TENANT_ID,)).fetchone():
conn.close()
if not list_companies(_DEFAULT_TENANT_ID):
_seed_companies(_DEFAULT_TENANT_ID, DEFAULT_DATA["companies"])
return _DEFAULT_TENANT_ID
conn.close()
salt, h = _hash_password("123456")
data = dict(DEFAULT_DATA)
conn = _conn()
conn.execute(
"INSERT INTO park_tenants (id,name,intro_json,username,salt,password_hash,data_json,agent_json,created_at) VALUES (?,?,?,?,?,?,?,?,?)",
(_DEFAULT_TENANT_ID, "昆明市大学生创业园", json.dumps(["云南省首家政府主办大学生创业孵化园区", "空间 + 孵化 + 融资 + 政策 + AI 赋能 + 综合服务"], ensure_ascii=False), "admin", salt, h, json.dumps(data, ensure_ascii=False), json.dumps({}, ensure_ascii=False), _now()),
)
conn.commit()
conn.close()
_seed_companies(_DEFAULT_TENANT_ID, DEFAULT_DATA["companies"])
refresh_engine(_DEFAULT_TENANT_ID)
return _DEFAULT_TENANT_ID
def _summary(tid: str, name: str, intro: list, username: str) -> dict:
return {"id": tid, "name": name, "intro": intro, "username": username}
def _seed_companies(tenant_id: str, companies: list) -> None:
"""把默认企业灌入 park_companies 表(新园区初始名录),管理端在其上改。"""
conn = _conn()
for c in companies:
name = c["name"] if isinstance(c, dict) else c
conn.execute(
"INSERT OR IGNORE INTO park_companies (id,tenant_id,name,zone,room,industry,bio,founder,status,employees,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(_new_id("PC"), tenant_id, name, c.get("zone", "") if isinstance(c, dict) else "", c.get("room", "") if isinstance(c, dict) else "", c.get("industry", "") if isinstance(c, dict) else "", c.get("bio", "") if isinstance(c, dict) else "", c.get("founder", "") if isinstance(c, dict) else "", "active", None, _now()),
)
conn.commit()
conn.close()
def iter_tenants():
for t in list_tenants():
yield t["id"], t
# ---------------- 入驻企业(表级 CRUD ----------------
def list_companies(tenant_id: str) -> list[dict]:
conn = _conn()
rows = conn.execute("SELECT * FROM park_companies WHERE tenant_id=? ORDER BY created_at DESC", (tenant_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
def create_company(tenant_id: str, payload: dict) -> dict:
row = {
"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(),
}
conn = _conn()
conn.execute(
"INSERT INTO park_companies (id,tenant_id,name,zone,room,industry,bio,founder,status,employees,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(row["id"], row["tenant_id"], row["name"], row["zone"], row["room"], row["industry"], row["bio"], row["founder"], row["status"], row["employees"], row["created_at"]),
)
conn.commit()
conn.close()
refresh_engine(tenant_id)
return {k: v for k, v in row.items() if k != "tenant_id"}
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
sets = ", ".join(f"{k}=?" for k in fields)
conn = _conn()
cur = conn.execute(f"UPDATE park_companies SET {sets} WHERE id=? AND tenant_id=?", list(fields.values()) + [cid, tenant_id])
conn.commit()
conn.close()
refresh_engine(tenant_id)
if cur.rowcount == 0:
return None
conn = _conn()
row = conn.execute("SELECT * FROM park_companies WHERE id=? AND tenant_id=?", (cid, tenant_id)).fetchone()
conn.close()
return {k: v for k, v in dict(row).items() if k != "tenant_id"}
def delete_company(tenant_id: str, cid: str) -> bool:
conn = _conn()
cur = conn.execute("DELETE FROM park_companies WHERE id=? AND tenant_id=?", (cid, tenant_id))
conn.commit()
conn.close()
refresh_engine(tenant_id)
return cur.rowcount > 0
# ---------------- 园区智能体 ----------------
def get_agent(tenant_id: str) -> dict:
return get_tenant(tenant_id).get("agent", {})
def update_agent(tenant_id: str, payload: dict) -> dict:
t = get_tenant(tenant_id)
agent = t.get("agent", {})
for k, v in payload.items():
if v is not None:
agent[k] = v
conn = _conn()
conn.execute("UPDATE park_tenants SET agent_json=? WHERE id=?", (json.dumps(agent, ensure_ascii=False), tenant_id))
conn.commit()
conn.close()
return agent
# ---------------- 园区知识库(表级 CRUD ----------------
def kb_docs(tenant_id: str) -> list[dict]:
conn = _conn()
rows = conn.execute("SELECT * FROM park_kb_docs WHERE tenant_id=? ORDER BY created_at DESC", (tenant_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
def create_kb_doc(tenant_id: str, payload: dict) -> dict:
row = {"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()}
conn = _conn()
conn.execute("INSERT INTO park_kb_docs (id,tenant_id,grp,title,content_md,created_at) VALUES (?,?,?,?,?,?)", (row["id"], row["tenant_id"], row["grp"], row["title"], row["content_md"], row["created_at"]))
conn.commit()
conn.close()
return {k: v for k, v in row.items() if k != "tenant_id"}
def update_kb_doc(tenant_id: str, did: str, payload: dict) -> dict | None:
fields = {k: v for k, v in payload.items() if v is not None and k in ("grp", "title", "content_md")}
if not fields:
return get_kb_doc(tenant_id, did)
sets = ", ".join(f"{k}=?" for k in fields)
conn = _conn()
conn.execute(f"UPDATE park_kb_docs SET {sets} WHERE id=? AND tenant_id=?", list(fields.values()) + [did, tenant_id])
conn.commit()
conn.close()
return get_kb_doc(tenant_id, did)
def delete_kb_doc(tenant_id: str, did: str) -> bool:
conn = _conn()
cur = conn.execute("DELETE FROM park_kb_docs WHERE id=? AND tenant_id=?", (did, tenant_id))
conn.commit()
conn.close()
return cur.rowcount > 0
def get_kb_doc(tenant_id: str, did: str) -> dict | None:
conn = _conn()
row = conn.execute("SELECT * FROM park_kb_docs WHERE id=? AND tenant_id=?", (did, tenant_id)).fetchone()
conn.close()
return {k: v for k, v in dict(row).items() if k != "tenant_id"} if row else None
# ---------------- 大屏数据(名称/简介 + 锚点) ----------------
def screen_view(tenant_id: str) -> dict:
t = 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"),
"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"),
}
def update_screen(tenant_id: str, payload: dict) -> dict:
t = get_tenant(tenant_id) or {}
d = dict(t.get("data", {}))
if payload.get("name"):
d["_name"] = payload["name"] # 名称存租户 name 列,见下
park = dict(d.get("park", {}))
for k in ("founded", "area", "address", "phone", "email"):
if payload.get(k) is not None:
park[k] = payload[k]
d["park"] = park
for k, sub in (("capacity", "projects"), ("invested", "projects"), ("jobs", "jobs"), ("revenue_total", "revenue"), ("revenue_tax", "revenue")):
if payload.get(k) is not None:
s = dict(d.get(sub, {}))
target = "total" if sub in ("jobs", "revenue") and k != "revenue_total" and k != "revenue_tax" else ("capacity" if k == "capacity" else "invested" if k == "invested" else ("tax" if k == "revenue_tax" else "total"))
if sub == "revenue":
target = "total" if k == "revenue_total" else "tax"
if sub == "jobs":
target = "total"
s[target] = payload[k]
d[sub] = s
conn = _conn()
name = payload.get("name") or t["name"]
intro = payload.get("intro") or t.get("intro", [])
conn.execute("UPDATE park_tenants SET name=?, intro_json=?, data_json=? WHERE id=?",
(name, json.dumps(intro, ensure_ascii=False), json.dumps(d, ensure_ascii=False), tenant_id))
conn.commit()
conn.close()
refresh_engine(tenant_id)
return screen_view(tenant_id)