diff --git a/app/api/routers/rbac_admin.py b/app/api/routers/rbac_admin.py index 453a93e..1f69cd4 100644 --- a/app/api/routers/rbac_admin.py +++ b/app/api/routers/rbac_admin.py @@ -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} diff --git a/app/park/routers.py b/app/park/routers.py index 02f0ae0..926d8f1 100644 --- a/app/park/routers.py +++ b/app/park/routers.py @@ -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) diff --git a/app/park/tenants.py b/app/park/tenants.py index a48ba4a..7093273 100644 --- a/app/park/tenants.py +++ b/app/park/tenants.py @@ -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"),