217 lines
8.5 KiB
Python
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}
|