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

217 lines
8.5 KiB
Python

"""Light-app registry API (轻应用中心 / 应用管理).
Registered mini-applications, **global / shared** (no user scoping). Two kinds:
- ``window_open`` — launched in a new browser tab from the 应用列表 card grid.
- ``iframe`` — embedded under a parent sidebar menu (``mount_location``) and
injected into the sidebar as a child item.
Both kinds carry ``route_params`` — a list of descriptors the frontend resolves
at launch time (login params read from ``localStorage.userInfo``; theme params
use a literal value) and appends to ``url`` as query string.
Reads are open to any authenticated user; writes require admin.
Routes (prefix ``/api/light-apps``):
GET "" list apps (filters: app_type / mount_location / enabled_only / search)
POST "" create app (admin)
GET "/{app_id}" read one
PUT "/{app_id}" update one (admin)
DELETE "/{app_id}" delete one (admin)
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.light_apps import DuplicateLightAppNameError, LightAppStore
router = APIRouter(prefix="/api/light-apps", tags=["light-apps"])
_VALID_TYPES = {"window_open", "iframe"}
# ── schemas ──────────────────────────────────────────────────────────────
class RouteParam(BaseModel):
key: str = Field(..., min_length=1, max_length=128)
paramType: str = Field(..., pattern="^(login|theme)$")
# login: which userInfo field, or "other"
source: str | None = Field(default=None, max_length=64)
# login + source == "other": literal value
customValue: str | None = Field(default=None, max_length=2048)
# theme: the theme value (e.g. dark-blue / red / blue), editable
themeValue: str | None = Field(default=None, max_length=128)
class LightAppResponse(BaseModel):
app_id: str
name: str
description: str | None = None
app_type: str
url: str
route_params: list[RouteParam] = Field(default_factory=list)
mount_location: str | None = None
icon: str | None = None
enabled: bool = True
sort_order: int = 0
created_by: str | None = None
updated_by: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class LightAppListResponse(BaseModel):
apps: list[LightAppResponse]
class LightAppCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=127)
description: str | None = Field(default=None, max_length=1000)
app_type: str = Field(..., pattern="^(window_open|iframe)$")
url: str = Field(..., min_length=1)
route_params: list[RouteParam] = Field(default_factory=list)
mount_location: str | None = Field(default=None, max_length=64)
icon: str | None = Field(default=None, max_length=64)
enabled: bool = True
sort_order: int = 0
class LightAppUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=127)
description: str | None = Field(default=None, max_length=1000)
app_type: str | None = Field(default=None, pattern="^(window_open|iframe)$")
url: str | None = Field(default=None, min_length=1)
route_params: list[RouteParam] | None = None
mount_location: str | None = Field(default=None, max_length=64)
icon: str | None = Field(default=None, max_length=64)
enabled: bool | None = None
sort_order: int | None = None
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> LightAppStore:
store = getattr(request.app.state, "light_app_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Light app 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_payload(app_type: str | None, mount_location: str | None) -> None:
if app_type == "iframe" and not mount_location:
raise HTTPException(status_code=400, detail="iframe 类型必须选择挂载菜单")
def _dump_params(params) -> list[dict]:
return [p.model_dump() if isinstance(p, RouteParam) else dict(p) for p in (params or [])]
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=LightAppListResponse)
async def list_light_apps(
request: Request,
app_type: str | None = Query(default=None),
mount_location: str | None = Query(default=None),
enabled_only: bool = Query(default=False),
search: str | None = Query(default=None),
) -> LightAppListResponse:
store = _get_store(request)
rows = await store.list_apps(app_type=app_type, mount_location=mount_location, enabled_only=enabled_only)
if search:
needle = search.strip().lower()
rows = [r for r in rows if needle in str(r.get("name", "")).lower() or needle in str(r.get("description") or "").lower()]
return LightAppListResponse(apps=[LightAppResponse(**r) for r in rows])
@router.post("", response_model=LightAppResponse)
async def create_light_app(request: Request, body: LightAppCreateRequest) -> LightAppResponse:
await _require_admin(request)
_validate_payload(body.app_type, body.mount_location)
store = _get_store(request)
user_id = await get_current_user(request)
try:
row = await store.create_app(
{
"name": body.name,
"description": body.description,
"app_type": body.app_type,
"url": body.url,
"route_params": _dump_params(body.route_params),
"mount_location": body.mount_location if body.app_type == "iframe" else None,
"icon": body.icon,
"enabled": body.enabled,
"sort_order": body.sort_order,
"created_by": user_id,
}
)
except DuplicateLightAppNameError:
raise HTTPException(status_code=409, detail="应用名称已存在")
return LightAppResponse(**row)
@router.get("/{app_id}", response_model=LightAppResponse)
async def get_light_app(request: Request, app_id: str) -> LightAppResponse:
store = _get_store(request)
row = await store.get_app(app_id)
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
return LightAppResponse(**row)
@router.put("/{app_id}", response_model=LightAppResponse)
async def update_light_app(request: Request, app_id: str, body: LightAppUpdateRequest) -> LightAppResponse:
await _require_admin(request)
store = _get_store(request)
user_id = await get_current_user(request)
fields = body.model_dump(exclude_unset=True)
if "route_params" in fields:
fields["route_params"] = _dump_params(body.route_params)
# Resolve the effective type/mount to validate the iframe→mount invariant.
existing = await store.get_app(app_id)
if existing is None:
raise HTTPException(status_code=404, detail="应用不存在")
eff_type = fields.get("app_type", existing.get("app_type"))
eff_mount = fields.get("mount_location", existing.get("mount_location"))
_validate_payload(eff_type, eff_mount)
if eff_type == "window_open":
fields["mount_location"] = None
fields["updated_by"] = user_id
try:
row = await store.update_app(app_id, fields)
except DuplicateLightAppNameError:
raise HTTPException(status_code=409, detail="应用名称已存在")
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
return LightAppResponse(**row)
@router.delete("/{app_id}")
async def delete_light_app(request: Request, app_id: str) -> dict:
await _require_admin(request)
store = _get_store(request)
deleted = await store.delete_app(app_id)
if not deleted:
raise HTTPException(status_code=404, detail="应用不存在")
return {"success": True}