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:
@@ -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}
|
||||
|
||||
+46
-8
@@ -21,7 +21,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..infrastructure.db import get_session
|
||||
from ..infrastructure.repositories import TaskRepository
|
||||
from ..rbac import require_roles, write_audit
|
||||
from ..jwt import decode_access_token
|
||||
from ..rbac import write_audit
|
||||
from ..api.schemas.admin import ParkAdmissionReviewRequest, ParkTransferReviewRequest
|
||||
from . import park_config, tenants
|
||||
from .auth import create_device_token, create_token, parse_device_token, parse_token, require_tenant
|
||||
@@ -148,6 +149,8 @@ async def tenant_bind(tid: str, body: BindBody):
|
||||
if user is None:
|
||||
return JSONResponse({"ok": False, "error": "账号不存在"}, status_code=404)
|
||||
ok = await tenants.bind_admin(tid, body.username.strip(), operator_user_id=user_id)
|
||||
# 绑定即成为该园区的载体方(carrier):账号单一角色,由后台绑定设置(登录后按 carrier 进园区工作台)。
|
||||
await db.users.set_role(user_id, "carrier", None, None, None)
|
||||
return {"ok": ok, "tenant_id": tid, "admin_username": body.username.strip()}
|
||||
|
||||
|
||||
@@ -881,19 +884,54 @@ async def _carrier_db():
|
||||
await db.close()
|
||||
|
||||
|
||||
async def _carrier_user(request: Request) -> dict:
|
||||
"""园区端鉴权(平台一套):解析平台 Bearer 登录令牌,要求业务角色 carrier(载体方)。
|
||||
|
||||
park 子应用挂在 dispatcher 下,request.app.state.db 未设置,故不能用平台 require_roles
|
||||
(它读 app.state.db);这里直接 decode_access_token 验证 + role=carrier。
|
||||
园区端管理员以平台 carrier 身份登录进入,不另立 park-token 鉴权。
|
||||
"""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="缺少登录令牌")
|
||||
payload = decode_access_token(auth.split(" ", 1)[1])
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||
if payload.get("role") != "carrier":
|
||||
raise HTTPException(status_code=403, detail="仅园区载体方可访问")
|
||||
return {"id": payload.get("sub", ""), "username": payload.get("username", ""), "role": "carrier"}
|
||||
|
||||
|
||||
async def _my_park(user: dict) -> dict:
|
||||
"""当前 carrier 账号关联的园区(operator_user_id),未关联则拒。"""
|
||||
"""当前 carrier 账号关联的园区,未关联则拒。
|
||||
|
||||
主键 operator_user_id(绑定园区管理员时写入);兜底用 username 匹配
|
||||
park_tenants.admin_username(历史/种子创建的园区管理员,operator_user_id 未回填)。
|
||||
"""
|
||||
t = await tenants.find_by_operator_user_id(user["id"])
|
||||
if not t and user.get("username"):
|
||||
t = await tenants.find_by_admin(user["username"])
|
||||
if not t:
|
||||
raise HTTPException(status_code=403, detail="该账号未关联任何园区")
|
||||
return t
|
||||
|
||||
|
||||
@router.get("/api/park-admissions", summary="园区入驻申请(本园区, carrier)")
|
||||
|
||||
|
||||
@router.get("/api/my-tenant", summary="当前 carrier 绑定园区")
|
||||
async def my_tenant(
|
||||
db=Depends(_carrier_db),
|
||||
user: dict = Depends(_carrier_user),
|
||||
):
|
||||
t = await _my_park(user)
|
||||
return {"ok": True, "tenant": {"id": t["id"], "name": t.get("name", ""), "intro": t.get("intro", [])}}
|
||||
|
||||
|
||||
@router.get("/api/park-admissions", summary="园区入驻申请(本园区, 平台 carrier 身份)")
|
||||
async def carrier_park_admissions(
|
||||
status: str = "",
|
||||
db=Depends(_carrier_db),
|
||||
user: dict = Depends(require_roles("carrier")),
|
||||
user: dict = Depends(_carrier_user),
|
||||
):
|
||||
t = await _my_park(user)
|
||||
return {"items": await db.park_admissions.list(status or None, tenant_id=t["id"])}
|
||||
@@ -905,7 +943,7 @@ async def carrier_park_admission_review(
|
||||
body: ParkAdmissionReviewRequest,
|
||||
request: Request,
|
||||
db=Depends(_carrier_db),
|
||||
user: dict = Depends(require_roles("carrier")),
|
||||
user: dict = Depends(_carrier_user),
|
||||
):
|
||||
t = await _my_park(user)
|
||||
adm = await db.park_admissions.get(aid)
|
||||
@@ -926,11 +964,11 @@ async def carrier_park_admission_review(
|
||||
return {"ok": True, "admission": updated}
|
||||
|
||||
|
||||
@router.get("/api/park-transfers", summary="OPC 转园申请(本园区相关, carrier)")
|
||||
@router.get("/api/park-transfers", summary="OPC 转园申请(本园区相关, 平台 carrier 身份)")
|
||||
async def carrier_park_transfers(
|
||||
status: str = "",
|
||||
db=Depends(_carrier_db),
|
||||
user: dict = Depends(require_roles("carrier")),
|
||||
user: dict = Depends(_carrier_user),
|
||||
):
|
||||
t = await _my_park(user)
|
||||
return {"items": await db.park_transfers.list(status or None, park_id=t["id"])}
|
||||
@@ -942,7 +980,7 @@ async def carrier_park_transfer_review(
|
||||
body: ParkTransferReviewRequest,
|
||||
request: Request,
|
||||
db=Depends(_carrier_db),
|
||||
user: dict = Depends(require_roles("carrier")),
|
||||
user: dict = Depends(_carrier_user),
|
||||
):
|
||||
t = await _my_park(user)
|
||||
tr = await db.park_transfers.get(tid)
|
||||
|
||||
@@ -401,6 +401,33 @@ async def list_screens(tenant_id: str) -> list[dict]:
|
||||
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"),
|
||||
|
||||
Reference in New Issue
Block a user