Files
server-core/app/park/tenants.py
T
Pine 8697119aaa feat(park-company): 园区企业→成员 + 企业算力(折扣%/配额,只增不减) 后端
- 迁移0015:park_companies 加 compute_discount/compute_quota/compute_quota_used/engine_group/owner_user_id;users 加 park_company_id(成员归属一家企业)
- tenants.py:company_members/get/add/remove(写 users.park_company_id)、available_members(候选)、set_company_compute(折扣%/配额 单调非降,越界 ValueError)、sync_company_engine(引擎组倍率同步,best-effort)
- park/routers.py:carrier 端点 company-members GET/POST/DELETE、company-pool、companies/{cid}/compute(monotonic+引擎同步);鉴权 _carrier_user+_my_park 隔离本园区
- compute_client.py:set_user_group_by_username、set_group_group_ratio(组倍率=1-discount%)、grant_user_quota_by_username(解析引擎id后 add_quota)
- rbac_admin.py:平台兜底 GET /park/tenants/{tid}/companies、PUT .../companies/{cid}/compute(monotonic+audit)
- _user_to_dict 暴露 park_company_id
- 单调校验服务端强判:折扣降/配额负增 → 400

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 19:57:37 +08:00

733 lines
29 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, func, 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, User
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,
"compute_discount": r.compute_discount, "compute_quota": r.compute_quota,
"compute_quota_used": r.compute_quota_used, "engine_group": r.engine_group,
"owner_user_id": r.owner_user_id}
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, operator_user_id: str = "") -> bool:
async with _get_session() as s:
r = await s.execute(update(ParkTenant).where(ParkTenant.id == tenant_id)
.values(admin_username=username.strip(), operator_user_id=operator_user_id or ""))
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 find_by_operator_user_id(user_id: str) -> dict | None:
"""carrier 载体账号据此定位本园区(园区端只审本片区)。"""
async with _get_session() as s:
row = (await s.execute(select(ParkTenant).where(ParkTenant.operator_user_id == user_id).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()
out = []
for r in rows:
d = _co(r)
d["member_count"] = await company_member_count(s, r.id)
out.append(d)
return out
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", "owner_user_id")}
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_company(company_id: str) -> dict | None:
async with _get_session() as s:
row = await s.get(ParkCompany, company_id)
return _co(row) if row else None
async def company_member_count(s, company_id: str) -> int:
"""统计企业成员数(users.park_company_id 命中)。"""
return await s.scalar(select(func.count(User.id)).where(User.park_company_id == company_id)) or 0
async def company_members(company_id: str) -> list[dict]:
"""企业成员列表(用户侧归属)。"""
async with _get_session() as s:
rows = (await s.execute(
select(User).where(User.park_company_id == company_id).order_by(User.created_at.desc())
)).scalars().all()
return [{
"id": r.id, "username": r.username, "nickname": r.nickname or "",
"phone": r.phone or "", "role": r.role or "",
"certification_status": r.certification_status or "uncertified",
"compute_quota": r.compute_quota or 0, "compute_used_quota": r.compute_used_quota or 0,
"created_at": r.created_at or "",
} for r in rows]
async def add_company_member(company_id: str, user_id: str) -> dict | None:
"""把用户归属到该企业(成员)。"""
async with _get_session() as s:
u = await s.get(User, user_id)
c = await s.get(ParkCompany, company_id)
if u is None or c is None:
return None
u.park_company_id = company_id
u.updated_at = _now()
await s.commit()
return {"ok": True, "company_id": company_id, "user_id": user_id}
async def remove_company_member(company_id: str, user_id: str) -> dict | None:
"""把用户移出该企业(清空归属)。"""
async with _get_session() as s:
u = await s.get(User, user_id)
c = await s.get(ParkCompany, company_id)
if u is None or c is None:
return None
if u.park_company_id != company_id:
return {"ok": False, "reason": "该用户不隶属此企业"}
u.park_company_id = None
u.updated_at = _now()
await s.commit()
return {"ok": True, "company_id": company_id, "user_id": user_id}
async def available_members(tenant_id: str) -> list[dict]:
"""园区内可加入企业的成员候选:affiliation=park 且属本园区、尚未归属企业、且为 opc_member。"""
async with _get_session() as s:
rows = (await s.execute(
select(User).where(
User.affiliation == "park",
User.park_id == tenant_id,
User.role == "opc_member",
User.park_company_id.is_(None),
).order_by(User.created_at.desc())
)).scalars().all()
return [{"id": r.id, "username": r.username, "nickname": r.nickname or "",
"phone": r.phone or "", "certification_status": r.certification_status or "uncertified"} for r in rows]
async def set_company_compute(company_id: str, discount: int | None = None, quota_add: int = 0) -> dict:
"""企业算力:折扣%/配额只增不减(服务端强校验)。
- discount:单调非降,任何 < 现值 → raise ValueError「折扣只能增加不能降低」。
- quota_add:增量,负增量 → raise ValueError「配额只能增加不能降低」。
返回更新后的企业(含成员数)。
"""
async with _get_session() as s:
c = await s.get(ParkCompany, company_id)
if c is None:
raise LookupError("企业不存在")
if discount is not None:
discount = max(0, min(100, int(discount)))
if discount < c.compute_discount:
raise ValueError("折扣只能增加不能降低")
c.compute_discount = discount
if quota_add:
if quota_add < 0:
raise ValueError("配额只能增加不能降低")
c.compute_quota += int(quota_add)
await s.commit()
await s.refresh(c)
d = _co(c)
d["member_count"] = await company_member_count(s, company_id)
d["quota_add"] = int(quota_add or 0)
return d
async def sync_company_engine(company_id: str) -> dict:
"""按企业当前折扣同步引擎(best-effort):为成员设用户组 + 该组对默认组的倍率。
引擎计费 quota = modelPrice × groupRatio,故倍率 = 1 - discount% 即自动打折。失败不阻断。
"""
from ..services import compute_client
c = await get_company(company_id)
if not c:
return {"ok": False, "reason": "企业不存在"}
discount = c.get("compute_discount", 0)
group = c.get("engine_group") or f"pc_{company_id}"
if discount <= 0 and not group:
return {"ok": True}
members = await company_members(company_id)
for m in members:
try:
await compute_client.set_user_group_by_username(m["username"], group)
except Exception: # noqa: BLE001
pass
if discount > 0:
try:
await compute_client.set_group_group_ratio(group, "default", 1 - discount / 100)
except Exception: # noqa: BLE001
pass
return {"ok": True}
# ---------------- 智能体 ----------------
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]
def _online_device_ids() -> set[str]:
"""当前在线大屏 device_id 集合(MQTT 心跳,最近 SCREEN_TTL 内上报)。"""
try:
from .mqtt import hub
return set(hub._online_ids())
except Exception: # noqa: BLE001
return set()
async def list_all_screens() -> list[dict]:
"""全部屏幕(含未绑定,tenant_id=None),带归属园区名 + 在线状态。平台端屏幕管理用。"""
async with _get_session() as s:
rows = (await s.execute(
select(ParkScreen, ParkTenant.name)
.outerjoin(ParkTenant, ParkTenant.id == ParkScreen.tenant_id)
.order_by(ParkScreen.status, ParkScreen.created_at.desc())
)).all()
online = _online_device_ids()
out = []
for r, tname in rows:
d = _sc(r)
d["tenant_name"] = tname or ""
d["online"] = bool(d.get("device_id")) and d["device_id"] in online
out.append(d)
return out
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("/"))