"""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}