dify/api/services/installed_app_service.py
非法操作 c1fdd6fb78
chore: paginate installed apps (#39739)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-29 09:51:34 +00:00

178 lines
6.6 KiB
Python

from datetime import datetime
from pydantic import BaseModel
from sqlalchemy import and_, exists, or_, select
from sqlalchemy.orm import Session, scoped_session
from libs.helper import escape_like_pattern
from models import App, AppModelConfig, InstalledApp, Workflow
from models.model import AppMode
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
class InstalledAppCursor(BaseModel):
is_pinned: bool
last_used_at: datetime | None
installed_app_id: str
def _published_app_filter():
"""Return the SQL predicate for installed-app web API availability.
The installed-app parameters endpoint reads the published workflow for
workflow-style apps and the published app model config for easy UI apps.
Keep the list endpoint aligned in SQL so it does not return entries that
will immediately fail with app_unavailable when opened.
"""
workflow_app_modes = (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW)
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
return and_(
App.mode != AppMode.AGENT,
or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
),
)
def _installed_app_cursor_filter(cursor: InstalledAppCursor):
same_pin_group = InstalledApp.is_pinned == cursor.is_pinned
if cursor.last_used_at is None:
later_in_pin_group = and_(
InstalledApp.last_used_at.is_(None),
InstalledApp.id > cursor.installed_app_id,
)
else:
later_in_pin_group = or_(
InstalledApp.last_used_at < cursor.last_used_at,
InstalledApp.last_used_at.is_(None),
and_(
InstalledApp.last_used_at == cursor.last_used_at,
InstalledApp.id > cursor.installed_app_id,
),
)
if cursor.is_pinned:
return or_(
InstalledApp.is_pinned.is_(False),
and_(same_pin_group, later_in_pin_group),
)
return and_(same_pin_group, later_in_pin_group)
def _installed_app_order_by():
return (
InstalledApp.is_pinned.desc(),
InstalledApp.last_used_at.desc().nulls_last(),
InstalledApp.id.asc(),
)
def _filter_rows_by_webapp_auth(
rows: list[tuple[InstalledApp, App]],
*,
user_id: str,
) -> list[tuple[InstalledApp, App]]:
if not rows:
return []
app_ids = [app.id for _, app in rows]
webapp_settings = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids)
candidates = [
(installed_app, app)
for installed_app, app in rows
if (setting := webapp_settings.get(app.id)) is not None and setting.access_mode != "sso_verified"
]
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
user_id=user_id,
app_ids=[app.id for _, app in candidates],
)
return [(installed_app, app) for installed_app, app in candidates if permissions.get(app.id)]
class InstalledAppService:
@classmethod
def get_visible_page(
cls,
*,
tenant_id: str,
user_id: str,
cursor: InstalledAppCursor | None,
limit: int,
app_id: str | None,
name: str | None,
session: Session | scoped_session,
) -> tuple[list[tuple[InstalledApp, App]], bool, InstalledAppCursor | None]:
"""Scan ordered candidates until one page of authorized apps is complete."""
stmt = (
select(InstalledApp, App)
.join(App, App.id == InstalledApp.app_id)
.where(InstalledApp.tenant_id == tenant_id, _published_app_filter())
)
if app_id:
stmt = stmt.where(InstalledApp.app_id == app_id)
if name and (normalized_name := name.strip()):
escaped_name = escape_like_pattern(normalized_name)
stmt = stmt.where(App.name.ilike(f"%{escaped_name}%", escape="\\"))
webapp_auth_enabled = FeatureService.get_system_features().webapp_auth.enabled
scan_size = limit * 2 if webapp_auth_enabled else limit + 1
visible_rows: list[tuple[InstalledApp, App]] = []
scan_cursor = cursor
has_more = False
last_consumed_app: InstalledApp | None = None
while True:
page_stmt = stmt
if scan_cursor is not None:
page_stmt = page_stmt.where(_installed_app_cursor_filter(scan_cursor))
candidate_result = session.execute(page_stmt.order_by(*_installed_app_order_by()).limit(scan_size)).all()
candidate_rows = [(installed_app, app) for installed_app, app in candidate_result]
if not candidate_rows:
break
authorized_rows = candidate_rows
if webapp_auth_enabled:
authorized_rows = _filter_rows_by_webapp_auth(candidate_rows, user_id=user_id)
authorized_installed_app_ids = {installed_app.id for installed_app, _ in authorized_rows}
for row in candidate_rows:
installed_app = row[0]
if installed_app.id not in authorized_installed_app_ids:
last_consumed_app = installed_app
continue
if len(visible_rows) == limit:
has_more = True
break
visible_rows.append(row)
last_consumed_app = installed_app
if has_more:
break
if len(candidate_rows) < scan_size:
break
last_scanned_app = candidate_rows[-1][0]
scan_cursor = InstalledAppCursor(
is_pinned=last_scanned_app.is_pinned,
last_used_at=last_scanned_app.last_used_at,
installed_app_id=last_scanned_app.id,
)
next_cursor = (
InstalledAppCursor(
is_pinned=last_consumed_app.is_pinned,
last_used_at=last_consumed_app.last_used_at,
installed_app_id=last_consumed_app.id,
)
if has_more and last_consumed_app
else None
)
return visible_rows, has_more, next_cursor
@staticmethod
def get_published_app(app_id: str, *, session: Session | scoped_session) -> App | None:
return session.scalar(select(App).where(App.id == app_id, _published_app_filter()).limit(1))