deerflow-code/offline-backend-20260512/backend/app/gateway/routers/menu_overrides.py
2026-09-07 18:24:55 +08:00

124 lines
5.0 KiB
Python

"""Sidebar-menu override API (菜单管理).
The static sidebar menu tree (icons / routes / adminOnly) is defined in the
frontend code; iframe light-apps are injected into it at render time. This
router persists a **global / shared** set of per-node overrides that the
frontend merges onto that tree: rename (``label``), 停用/启用 (``disabled``),
reorder (``sort_order``) and re-parent (``parent_id``, moving a node's whole
subtree). A node with no override renders exactly as the code defines it.
Reads are open to any authenticated user (the sidebar needs them); the whole
override set is replaced atomically and only by an admin.
Routes (prefix ``/api/menu-overrides``):
GET "" list all overrides
PUT "" replace the entire override set (admin)
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.menu_overrides import MenuOverrideStore
router = APIRouter(prefix="/api/menu-overrides", tags=["menu-overrides"])
# Hard cap mirroring the sidebar's 一级 → 二级 → 三级 rendering depth.
_MAX_DEPTH = 3
# ── schemas ──────────────────────────────────────────────────────────────
class MenuOverride(BaseModel):
node_id: str = Field(..., min_length=1, max_length=128)
parent_id: str | None = Field(default=None, max_length=128)
sort_order: int = 0
label: str | None = Field(default=None, max_length=255)
disabled: bool = False
class MenuOverrideResponse(MenuOverride):
updated_by: str | None = None
updated_at: datetime | str | None = None
class MenuOverrideListResponse(BaseModel):
overrides: list[MenuOverrideResponse]
class MenuOverrideReplaceRequest(BaseModel):
overrides: list[MenuOverride] = Field(default_factory=list)
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> MenuOverrideStore:
store = getattr(request.app.state, "menu_override_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Menu override store not available")
return store
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="菜单管理仅限管理员")
def _validate_depth(overrides: list[MenuOverride]) -> None:
"""Reject cycles and any node whose effective depth exceeds 3 levels.
Only nodes that appear in the override set carry an explicit ``parent_id``;
a node absent from the set keeps its code-defined parent (which is at most
3 levels deep by construction). So validating the override graph alone is
sufficient to stop an admin from pushing a 4th level via re-parenting.
"""
parent_of = {o.node_id: o.parent_id for o in overrides}
for node_id in parent_of:
depth = 1
seen = {node_id}
cursor = parent_of[node_id]
while cursor:
depth += 1
if depth > _MAX_DEPTH:
raise HTTPException(status_code=400, detail=f"菜单「{node_id}」层级超过 {_MAX_DEPTH} 级")
if cursor in seen:
raise HTTPException(status_code=400, detail=f"菜单「{node_id}」存在循环引用")
seen.add(cursor)
# A parent that is not itself in the override set is a code node at a
# known depth ≤ 3, so the chain terminates there.
cursor = parent_of.get(cursor)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=MenuOverrideListResponse)
async def list_menu_overrides(request: Request) -> MenuOverrideListResponse:
store = _get_store(request)
rows = await store.list_overrides()
return MenuOverrideListResponse(overrides=[MenuOverrideResponse(**r) for r in rows])
@router.put("", response_model=MenuOverrideListResponse)
async def replace_menu_overrides(request: Request, body: MenuOverrideReplaceRequest) -> MenuOverrideListResponse:
await _require_admin(request)
_validate_depth(body.overrides)
store = _get_store(request)
user_id = await get_current_user(request)
rows = await store.replace_all(
[o.model_dump() for o in body.overrides],
updated_by=user_id,
)
return MenuOverrideListResponse(overrides=[MenuOverrideResponse(**r) for r in rows])