Files
server-core/app/park/tenants.py
T
Pine c8870d5b3b 算力余额体系闭环:企业分配/个人充值分离记账+退出返还
- users 新增 compute_personal_balance(分):个人充值/企业转入个人账本,
  与 company_members.compute_balance(企业分配)彻底分离
- relay 转发引擎后按实际用量记账(deduct_usage_post):
  先扣企业分配余额(折扣低→高)、不足扣个人余额,写 compute_usage_records;
  流式 SSE 透传时解析 usage chunk 记账,JWT 登录态调用生效、PAT 调用不重复记账
- 个人充值到账(pay/service)与 企业↔个人互转 同步更新个人账本
- 成员退出企业(leave)与管理员移出成员(remove_company_member):
  仅返还企业分配剩余(member.compute_balance)至企业余额,同步扣减引擎 quota,
  绝不触碰个人充值余额
- GET /compute/user/balance 个人余额改读独立账本(不再引擎倒推)
- 迁移 0067;存量个人充值/互转净额已回填
2026-09-07 12:19:50 +08:00

1099 lines
48 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 (
CompanyMember,
ParkCompany,
ParkKbDoc,
ParkMember,
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 text
from ..infrastructure.db import make_sync_engine
engine = make_sync_engine(DATABASE_URL) # 按 DATABASE_URL 映射同步驱动(sqlite/pymysql
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 ..infrastructure.db import make_sync_engine
return make_sync_engine(DATABASE_URL)
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,
"tenant_id": getattr(r, "tenant_id", None) or "", "company_kind": getattr(r, "company_kind", "park_entered"),
"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,
"legal_person": getattr(r, "legal_person", ""), "legal_phone": getattr(r, "legal_phone", ""),
"registered_capital": getattr(r, "registered_capital", ""), "company_type": getattr(r, "company_type", ""),
"honors": getattr(r, "honors", ""), "address": getattr(r, "address", ""),
"contact_phone": getattr(r, "contact_phone", ""), "founded_at": getattr(r, "founded_at", ""),
"emp_total": getattr(r, "emp_total", None), "emp_grad": getattr(r, "emp_grad", None),
"emp_layoff": getattr(r, "emp_layoff", None), "emp_veteran": getattr(r, "emp_veteran", None),
"emp_migrant": getattr(r, "emp_migrant", None)}
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, seed_demo: bool = False) -> dict:
"""创建园区。seed_demo=False(默认)不注入演示企业——演示数据仅限自举默认园区。"""
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()
if seed_demo:
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, demote_user_id: str = "") -> bool:
"""解绑园区管理员:清 admin_username 与 operator_user_id(修复旧版漏清导致 _my_park 仍命中)。"""
async with _get_session() as s:
r = await s.execute(update(ParkTenant).where(ParkTenant.id == tenant_id)
.values(admin_username="", operator_user_id=""))
await s.commit()
ok = r.rowcount > 0
if ok and demote_user_id:
await s.execute(update(ParkMember).where(ParkMember.park_id == tenant_id,
ParkMember.user_id == demote_user_id)
.values(member_type="staff"))
await s.commit()
return ok
async def demote_park_admin(park_id: str, user_id: str) -> None:
"""解绑管理员时把 park_members 中 admin 记录降级为 staff。"""
async with _get_session() as s:
await s.execute(update(ParkMember).where(ParkMember.park_id == park_id,
ParkMember.user_id == user_id)
.values(member_type="staff"))
await s.commit()
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 resolve_company_owners(s, items: list[dict]) -> list[dict]:
"""批量解析企业 owner_user_id → 用户展示信息(一处实现,各列表接口复用)。
owner 用户存在:填充 owner_username / owner_nickname / owner_deleted=False
owner 用户已删除(孤儿引用):owner_deleted=Trueowner_username=""(前端据此显示"已删除账号");
owner 未绑定:三字段空。
"""
owner_ids = {d.get("owner_user_id") for d in items if d.get("owner_user_id")}
users = {}
if owner_ids:
for u in (await s.execute(select(User).where(User.id.in_(owner_ids)))).scalars().all():
users[u.id] = u
for d in items:
oid = d.get("owner_user_id") or ""
d["owner_username"] = ""
d["owner_nickname"] = ""
d["owner_deleted"] = False
if oid:
u = users.get(oid)
if u is None:
d["owner_deleted"] = True
else:
d["owner_username"] = u.username or ""
d["owner_nickname"] = u.nickname or ""
return items
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 await resolve_company_owners(s, out)
_COMPANY_FIELDS = ("name", "zone", "room", "industry", "bio", "founder", "status", "employees",
"owner_user_id", "legal_person", "legal_phone", "registered_capital", "company_type",
"honors", "address", "contact_phone", "founded_at",
"emp_total", "emp_grad", "emp_layoff", "emp_veteran", "emp_migrant")
async def create_company(tenant_id: str, payload: dict) -> dict:
"""创建企业。tenant_id 为空串/None → 独立企业(company_kind=independent)。"""
kind = payload.get("company_kind") or ("park_entered" if tenant_id else "independent")
row = ParkCompany(id=_new_id("PC"), tenant_id=tenant_id or None, company_kind=kind,
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"),
legal_person=payload.get("legal_person", ""), legal_phone=payload.get("legal_phone", ""),
registered_capital=payload.get("registered_capital", ""), company_type=payload.get("company_type", ""),
honors=payload.get("honors", ""), address=payload.get("address", ""),
contact_phone=payload.get("contact_phone", ""), founded_at=payload.get("founded_at", ""),
emp_total=payload.get("emp_total"), emp_grad=payload.get("emp_grad"),
emp_layoff=payload.get("emp_layoff"), emp_veteran=payload.get("emp_veteran"),
emp_migrant=payload.get("emp_migrant"), owner_user_id=payload.get("owner_user_id", ""),
created_at=_now())
async with _get_session() as s:
s.add(row)
await s.commit()
if tenant_id:
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 _COMPANY_FIELDS}
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
old_owner = row.owner_user_id or ""
for k, v in fields.items():
setattr(row, k, v)
# owner_user_id 变化时同步 company_members.is_admin(单一事实源:企业管理员=owner)
if "owner_user_id" in fields:
new_owner = fields["owner_user_id"] or ""
if new_owner and new_owner != old_owner:
# 清旧管理员
for old in (await s.execute(select(CompanyMember).where(
CompanyMember.company_id == cid, CompanyMember.is_admin.is_(True)))).scalars().all():
old.is_admin = False
if old.member_type in ("admin", "owner"):
old.member_type = "staff"
# 新 owner 须为成员;不是则添加
m = (await s.execute(select(CompanyMember).where(
CompanyMember.company_id == cid, CompanyMember.user_id == new_owner))).scalars().first()
if m is None:
m = CompanyMember(id=_new_id("cm"), user_id=new_owner, company_id=cid,
created_at=_now())
s.add(m)
m.is_admin = True
m.member_type = "admin"
elif not new_owner and old_owner:
# 解绑 owner:清掉所有管理员标记
for old in (await s.execute(select(CompanyMember).where(
CompanyMember.company_id == cid, CompanyMember.is_admin.is_(True)))).scalars().all():
old.is_admin = False
if old.member_type in ("admin", "owner"):
old.member_type = "staff"
await s.commit()
await s.refresh(row)
refresh_engine(tenant_id, get_tenant_data_sync(tenant_id))
return _co(row)
async def set_company_owner(company_id: str, user_id: str) -> dict | None:
"""设置企业负责人/管理员:须已是企业成员(company_members);同步 owner_user_id + is_admin。"""
async with _get_session() as s:
c = await s.get(ParkCompany, company_id)
m = (await s.execute(select(CompanyMember).where(CompanyMember.company_id == company_id,
CompanyMember.user_id == user_id).limit(1))).scalars().first()
if c is None or m is None:
return {"ok": False, "reason": "该用户不隶属此企业,无法设为负责人"}
# 每企业一名管理员:清旧置新
for old in (await s.execute(select(CompanyMember).where(CompanyMember.company_id == company_id,
CompanyMember.is_admin.is_(True)))).scalars().all():
old.is_admin = False
if old.member_type in ("admin", "owner"):
old.member_type = "staff"
m.is_admin = True
m.member_type = "admin"
c.owner_user_id = user_id
# 同步 founder(展示字段单一事实源)
u = await s.get(User, user_id)
if u is not None:
c.founder = u.nickname or u.username or c.founder
await s.commit()
return {"ok": True, "company_id": company_id, "owner_user_id": user_id}
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:
"""统计企业成员数(company_members 多对多)。"""
return await s.scalar(select(func.count(CompanyMember.id)).where(
CompanyMember.company_id == company_id, CompanyMember.status == "active")) or 0
async def company_members(company_id: str) -> list[dict]:
"""企业成员列表(company_members JOIN usersis_admin 标注企业管理员)。"""
async with _get_session() as s:
rows = (await s.execute(
select(User, CompanyMember).join(CompanyMember, CompanyMember.user_id == User.id).where(
CompanyMember.company_id == company_id, CompanyMember.status == "active"
).order_by(CompanyMember.created_at)
)).all()
return [{
"id": u.id, "username": u.username, "nickname": u.nickname or "",
"phone": u.phone or "", "role": u.role or "",
"is_admin": bool(cm.is_admin), "member_type": cm.member_type,
"certification_status": u.certification_status or "uncertified",
"company": u.company or "", "gender": u.gender or "", "birthday": u.birthday or "",
"id_card": u.id_card or "", "ethnicity": u.ethnicity or "",
"grad_school_major": u.grad_school_major or "", "grad_time": u.grad_time or "",
"compute_quota": u.compute_quota or 0, "compute_used_quota": u.compute_used_quota or 0,
"compute_balance": getattr(cm, "compute_balance", 0) or 0,
"compute_balance_used": getattr(cm, "compute_balance_used", 0) or 0,
"created_at": u.created_at or "",
} for u, cm in rows]
async def add_company_member(company_id: str, user_id: str, *, is_admin: bool = False,
created_by: str = "") -> dict | None:
"""把用户绑定为该企业成员(company_members 多对多,幂等;支持跨园区用户——用户可属多企业多园区)。
铁律:仅绑定企业成员关系,绝不改动用户所属园区(park_id/park_members)——变动园区须走转园申请。
"""
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 is_admin:
for old in (await s.execute(select(CompanyMember).where(
CompanyMember.company_id == company_id, CompanyMember.is_admin.is_(True)))).scalars().all():
old.is_admin = False
if old.member_type in ("admin", "owner"):
old.member_type = "staff"
m = (await s.execute(select(CompanyMember).where(
CompanyMember.company_id == company_id, CompanyMember.user_id == user_id).limit(1))).scalars().first()
if m is None:
m = CompanyMember(id=f"cm_{uuid.uuid4().hex[:24]}", user_id=user_id, company_id=company_id,
created_at=_now())
s.add(m)
m.is_admin = bool(is_admin)
m.member_type = "admin" if is_admin else "staff"
m.status = "active"
if created_by:
m.created_by = created_by
if is_admin:
c.owner_user_id = user_id
# 展示冗余:park_company_id 仅作主企业缓存(原值兼容)
if not u.park_company_id:
u.park_company_id = company_id
u.updated_at = _now()
await s.commit()
return {"ok": True, "company_id": company_id, "user_id": user_id, "is_admin": bool(is_admin)}
async def remove_company_member(company_id: str, user_id: str) -> dict | None:
"""把用户移出该企业(删 company_members 行;展示缓存 park_company_id 命中则清空)。
企业分配的算力余额(company_members.compute_balance)同步返还企业并扣减引擎额度,
绝不触碰用户个人充值余额(users.compute_personal_balance)。
"""
async with _get_session() as s:
c = await s.get(ParkCompany, company_id)
u = await s.get(User, user_id)
if c is None or u is None:
return None
m = (await s.execute(select(CompanyMember).where(
CompanyMember.company_id == company_id, CompanyMember.user_id == user_id).limit(1))).scalars().first()
if m is None:
return {"ok": False, "reason": "该用户不隶属此企业"}
if m.is_admin:
return {"ok": False, "reason": "企业管理员须先移交管理员后才能移出"}
# 返还剩余企业分配余额(分),并同步引擎额度扣减(1分=10000 micro
remaining = int(getattr(m, "compute_balance", 0) or 0)
if remaining > 0:
c.compute_balance_used = max(0, int(c.compute_balance_used or 0) - remaining)
try:
from ..pay.service import _resolve_engine_user_id
from ..services import compute_client
engine_user_id = await _resolve_engine_user_id(u.username or "")
if engine_user_id:
await compute_client.adjust_user_quota(engine_user_id, remaining * 10000, "subtract")
# 手动回写引擎镜像(本层为裸 AsyncSession,不走 Database.sync_user_mirror
bal = await compute_client.user_balance(u.username or "")
u.compute_quota = int(bal.get("quota") or 0)
u.compute_used_quota = int(bal.get("used_quota") or 0)
except Exception as exc: # noqa: BLE001
import logging
logging.getLogger("tenants").warning(
"[remove_company_member] 引擎额度扣减失败 user=%s: %s", user_id, exc
)
await s.delete(m)
if u.park_company_id == company_id:
u.park_company_id = None
u.updated_at = _now()
await s.commit()
return {"ok": True, "company_id": company_id, "user_id": user_id, "refunded_balance": remaining}
async def available_members(tenant_id: str) -> list[dict]:
"""园区内可加入企业的成员候选:本园区 active 成员(park_members),不限是否已有企业(用户可属多企业)。"""
async with _get_session() as s:
rows = (await s.execute(
select(User, ParkMember).join(ParkMember, ParkMember.user_id == User.id).where(
ParkMember.park_id == tenant_id, ParkMember.status == "active",
User.role == "opc_member",
).order_by(ParkMember.created_at.desc())
)).all()
return [{"id": u.id, "username": u.username, "nickname": u.nickname or "",
"phone": u.phone or "", "certification_status": u.certification_status or "uncertified"}
for u, _pm in rows]
# ---------------- 园区成员(park_members 多对多) ----------------
async def park_members(tenant_id: str, status: str = "active") -> list[dict]:
"""本园区成员列表(park_members JOIN users)。"""
async with _get_session() as s:
rows = (await s.execute(
select(User, ParkMember).join(ParkMember, ParkMember.user_id == User.id).where(
ParkMember.park_id == tenant_id,
*[ParkMember.status == status] if status else [],
).order_by(ParkMember.created_at)
)).all()
from ..infrastructure.oss import resolve_url as _resolve_avatar
return [{
"id": u.id, "username": u.username, "nickname": u.nickname or "",
"phone": u.phone or "", "role": u.role or "",
"member_type": pm.member_type, "membership_status": pm.status,
"certification_status": u.certification_status or "uncertified",
"company": u.company or "", "avatar": _resolve_avatar(u.avatar or ""),
"gender": u.gender or "", "created_at": u.created_at or "",
} for u, pm in rows]
async def add_park_member(tenant_id: str, user_id: str, *, member_type: str = "staff",
created_by: str = "") -> dict:
"""幂等绑定用户到园区(park_members)。绝不改写 users.park_id 主归属缓存——由归属服务同步。"""
async with _get_session() as s:
m = (await s.execute(select(ParkMember).where(ParkMember.user_id == user_id,
ParkMember.park_id == tenant_id).limit(1))).scalars().first()
if m is None:
m = ParkMember(id=f"pm_{uuid.uuid4().hex[:24]}", user_id=user_id, park_id=tenant_id,
created_at=_now())
s.add(m)
m.member_type = member_type
m.status = "active"
if created_by:
m.created_by = created_by
await s.commit()
return {"ok": True, "park_id": tenant_id, "user_id": user_id, "member_type": member_type}
async def remove_park_member(tenant_id: str, user_id: str) -> dict | None:
async with _get_session() as s:
m = (await s.execute(select(ParkMember).where(ParkMember.user_id == user_id,
ParkMember.park_id == tenant_id).limit(1))).scalars().first()
if m is None:
return {"ok": False, "reason": "该用户不是本园区成员"}
if m.member_type == "admin":
return {"ok": False, "reason": "园区管理员须先解绑管理员"}
await s.delete(m)
await s.commit()
return {"ok": True, "park_id": tenant_id, "user_id": user_id}
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}
# ---------------- 载体资料(云南载体地图对外展示,0034 ----------------
# 载体资料可编辑字段(snake_case,对应 park_tenants 列)
PROFILE_KEYS = ("lng", "lat", "cover", "address", "city", "intro_html",
"policy_html", "admission_html", "contact", "tags", "stats_json")
def _profile_out(row) -> dict:
"""park_tenants 行 → 载体资料 camelCase dictdraft 已解析、stats 归一为 [{label,value}])。"""
try:
stats = json.loads(_val(row, "stats_json") or "[]")
except (TypeError, ValueError):
stats = []
try:
draft = json.loads(_val(row, "profile_draft_json") or "{}")
except (TypeError, ValueError):
draft = {}
return {
"lng": _val(row, "lng"), "lat": _val(row, "lat"),
"cover": _val(row, "cover") or "", "address": _val(row, "address") or "",
"city": _val(row, "city") or "",
"introHtml": _val(row, "intro_html") or "", "policyHtml": _val(row, "policy_html") or "",
"admissionHtml": _val(row, "admission_html") or "",
"contact": _val(row, "contact") or "", "tags": [t for t in (_val(row, "tags") or "").split(",") if t],
"stats": stats if isinstance(stats, list) else [],
"profileStatus": _val(row, "profile_status") or "approved",
"draft": draft,
"reviewComment": _val(row, "profile_review_comment") or "",
"reviewedAt": _val(row, "profile_reviewed_at") or "",
}
def _norm_profile_patch(patch: dict) -> dict:
"""入参归一(camelCase→snake_case 列名 + 类型约束),返回仅含合法键的 dict。"""
alias = {"introHtml": "intro_html", "policyHtml": "policy_html", "admissionHtml": "admission_html"}
out: dict = {}
for k, v in (patch or {}).items():
key = alias.get(k, k)
if key not in PROFILE_KEYS:
continue
if key in ("lng", "lat"):
try:
v = float(v) if v not in (None, "") else None
except (TypeError, ValueError):
continue
elif key == "stats_json":
v = json.dumps(v if isinstance(v, list) else [], ensure_ascii=False)
elif key == "tags":
v = ",".join(t.strip() for t in v) if isinstance(v, list) else str(v or "")
else:
v = str(v or "")
out[key] = v
return out
async def get_park_profile(tenant_id: str) -> dict | None:
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
return _profile_out(row) if row else None
async def update_park_profile(tenant_id: str, patch: dict) -> dict | None:
"""运营端直接编辑:即时生效(profile_status=approved),清空待审草案。"""
vals = _norm_profile_patch(patch)
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
if row is None:
return None
for k, v in vals.items():
setattr(row, k, v)
row.profile_status = "approved"
row.profile_draft_json = ""
row.profile_review_comment = ""
row.profile_reviewed_at = _now()
await s.commit()
return await get_park_profile(tenant_id)
async def submit_park_profile_draft(tenant_id: str, patch: dict) -> dict | None:
"""载体端提交改动:写入 profile_draft_jsonprofile_status=pending,等待运营端审核。"""
vals = _norm_profile_patch(patch)
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
if row is None:
return None
row.profile_draft_json = json.dumps(vals, ensure_ascii=False)
row.profile_status = "pending"
row.profile_review_comment = ""
await s.commit()
return await get_park_profile(tenant_id)
async def review_park_profile(tenant_id: str, approved: bool, comment: str = "") -> dict | None:
"""运营端审核载体端草案:通过→草案合并进正式字段;驳回→清草案留意见。"""
async with _get_session() as s:
row = await s.get(ParkTenant, tenant_id)
if row is None:
return None
try:
draft = json.loads(row.profile_draft_json or "{}")
except (TypeError, ValueError):
draft = {}
if approved and isinstance(draft, dict):
for k, v in _norm_profile_patch(draft).items():
setattr(row, k, v)
row.profile_status = "approved"
row.profile_draft_json = ""
row.profile_review_comment = comment or ""
row.profile_reviewed_at = _now()
await s.commit()
return await get_park_profile(tenant_id)
# ---------------- 智能体 ----------------
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("/"))