feat(park): 平台园区/屏幕绑定 + OPC认证入园转园审核 + 园区端carrier修复

- rbac_admin 新增 /admin/park/screens|tenants|devices/{id}/bind|unbind 及 OPC认证/入园/转园审核端点
- tenants.list_all_screens 带 online(MQTT心跳),bind/unbind 完备
- park/routers: tenant_bind 绑定即 set_role(carrier);_my_park 兜底 username→admin_username 解析园区(修"未关联任何园区")

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-26 19:02:58 +08:00
parent 745ba678bd
commit 97f0a0698c
3 changed files with 212 additions and 8 deletions
+139
View File
@@ -317,3 +317,142 @@ async def review_park_transfer(
await write_audit(db, action="opc.park_transfer_review", resource="park_transfer",
resource_id=tid, detail=f"status={req.status}", user=actor, request=request)
return {"ok": True, "transfer": updated}
# ==================== 平台园区管理(operator;复用 park 数据层,不依赖 app.state.db ====================
class AdminBindDeviceBody(BaseModel):
tenant_id: str
name: str = ""
role: str = "main"
location: str = ""
class AdminCreateTenantBody(BaseModel):
name: str
intro: list[str] = ["", ""]
username: str = ""
password: str = ""
class AdminTenantStatusBody(BaseModel):
status: str # active | disabled
def _park_tenant_public(t: dict) -> dict:
"""脱敏园区:剔除 auth(username/salt/password_hash),仅留管理用字段。"""
return {k: t.get(k) for k in ("id", "name", "intro", "admin_username", "status")}
@router.get("/park/screens", summary="全部屏幕(含未绑定+归属园区名,平台)")
async def admin_park_screens(
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
from app.park import tenants as tnt
return {"items": await tnt.list_all_screens()}
@router.get("/park/tenants", summary="园区列表(脱敏,平台)")
async def admin_park_tenants(
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
from app.park import tenants as tnt
rows = await tnt.list_tenants()
return {"items": [_park_tenant_public(t) for t in rows]}
@router.post("/park/tenants", summary="平台创建园区")
async def admin_park_create_tenant(
body: AdminCreateTenantBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
t = await tnt.create_tenant(body.name, body.intro, body.username, body.password)
await write_audit(db, action="park.create", resource="park_tenant",
resource_id=(t or {}).get("id", ""), detail=body.name, user=actor, request=request)
return {"ok": True, "tenant": _park_tenant_public(t or {})}
@router.delete("/park/tenants/{tid}", summary="平台删除园区(级联删屏)")
async def admin_park_delete_tenant(
tid: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
ok = await tnt.delete_tenant(tid)
if not ok:
raise HTTPException(status_code=404, detail="园区不存在")
await write_audit(db, action="park.delete", resource="park_tenant",
resource_id=tid, detail=tid, user=actor, request=request)
return {"ok": True}
@router.post("/park/tenants/{tid}/status", summary="平台停用/启用园区")
async def admin_park_tenant_status(
tid: str,
body: AdminTenantStatusBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
if body.status not in ("active", "disabled"):
raise HTTPException(status_code=400, detail="无效状态")
ok = await tnt.set_status(tid, body.status)
if not ok:
raise HTTPException(status_code=404, detail="园区不存在")
await write_audit(db, action="park.status", resource="park_tenant",
resource_id=tid, detail=body.status, user=actor, request=request)
return {"ok": True, "status": body.status}
@router.post("/park/devices/{device_id}/bind", summary="平台手动绑定屏幕到园区")
async def admin_park_bind_device(
device_id: str,
body: AdminBindDeviceBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
tenant = await tnt.get_tenant(body.tenant_id)
if tenant is None:
raise HTTPException(status_code=404, detail="目标园区不存在")
dev = await tnt.get_device_by_id(device_id)
if dev is None:
raise HTTPException(status_code=404, detail="屏幕不存在")
if dev.get("status") == "bound" and dev.get("tenant_id") and dev.get("tenant_id") != body.tenant_id:
raise HTTPException(status_code=400, detail="该屏幕已绑定到其它园区,请先解绑")
updated = await tnt.bind_device(body.tenant_id, device_id, body.name, body.role, body.location)
await write_audit(db, action="park.bind_screen", resource="park_screen",
resource_id=device_id, detail=f"tid={body.tenant_id}", user=actor, request=request)
return {"ok": True, "screen": updated}
@router.post("/park/devices/{device_id}/unbind", summary="平台解绑屏幕(从园区解绑,回未绑定)")
async def admin_park_unbind_device(
device_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
dev = await tnt.get_device_by_id(device_id)
if dev is None:
raise HTTPException(status_code=404, detail="屏幕不存在")
if dev.get("status") != "bound":
raise HTTPException(status_code=400, detail="该屏幕当前未绑定,无需解绑")
ok = await tnt.unbind_device(device_id)
if not ok:
raise HTTPException(status_code=500, detail="解绑失败")
await write_audit(db, action="park.unbind_screen", resource="park_screen",
resource_id=device_id, detail=f"from tid={dev.get('tenant_id')}", user=actor, request=request)
return {"ok": True}