From 495018de3487e55f54774adb97492f7cc0f2ef1a Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:36:17 +0800 Subject: [PATCH] update: workspace lazy load --- API.md | 42 ++++++++++- backend/src/backend/scripts.py | 74 +++++++++++++++++++ ...9f36_workspace_tree_relative_path_index.py | 38 ++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/3ba4d8489f36_workspace_tree_relative_path_index.py diff --git a/API.md b/API.md index fd93dc4..abb5be2 100644 --- a/API.md +++ b/API.md @@ -73,9 +73,12 @@ > 是它在对象存储里的"工作副本",`Versions` 是 immutable 的稳定版本。 > 写操作受 **is_locked 门禁 + owner 校验** 保护(架构 V3.1 §4)。 -### 3.1 `GET /api/v1/workspace-tree` +### 3.1 `GET /api/v1/workspace-tree` (**legacy / 全量视图**) -列出当前用户在 workspace 内的**目录树**。 +> **Deprecated**: 新代码请走 §3.3.1 (按 `parent_path` 单层)。该接口仍保留供调试 / 兼容使用, +> 谓词已统一为 `object_status='available' AND is_deleted=0`,与 §3.3.1 保持一致。 + +列出当前用户在 workspace 内的**整个目录树**(扁平数组)。 - **来源**: 显式 `StorageObjects` 行 (`object_type='directory'`,见 §3.2) **并入** 从 `Scripts.relative_path` 派生的祖先目录,**去重**。空目录(只有显式行、没有文件)也会出现。 - **鉴权**: workspace 成员 @@ -145,6 +148,41 @@ } ``` +### 3.3.1 `GET /api/v1/workspace-directories?parent_path=...` + +列出指定父目录下的**直接子目录**(单层),用于前端懒加载。**新代码请走本接口**;§3.1 仅作为 legacy / 全量保留。 + +- **行为**: `parent_path` 为空字符串或缺省 → 用户根目录;非空 → 该父目录的直接子目录(workspace-relative)。仅返回 `object_status='available' AND is_deleted=0` 的 `StorageObjects` 行。 +- **鉴权**: workspace 成员 +- **查询参数**: + | 名 | 类型 | 必填 | 说明 | + |---|---|---|---| + | `parent_path` | string | 否 | 父目录相对路径,空字符串或缺省表示用户根目录 | +- **谓词(SQL 等价)**: `relative_path LIKE '/%' AND relative_path NOT LIKE '/%/%'`,其中 `prefix = scoped_prefix/{parent_path}`,索引走 `idx_storage_workspace_relative_path(workspace_id, relative_path(255))`。 +- **响应**: + ```json + { + "request_id": "...", + "data": { + "directories": [ + {"path": "scripts", "name": "scripts", "parent_path": "", "has_children": true}, + {"path": "scripts/etl", "name": "etl", "parent_path": "scripts", "has_children": false}, + {"path": "scripts/etl/daily", "name": "daily", "parent_path": "scripts/etl", "has_children": false} + ] + }, + "meta": {"directory_count": 3} + } + ``` + - 字段表(继承 §3.1): + | 字段 | 类型 | 说明 | + |---|---|---| + | `path` | string | workspace 内相对路径 | + | `name` | string | `path` 的最后一段 | + | `parent_path` | string | 父目录相对路径,根目录用空串 | + | `has_children` | bool | 该目录下是否还有直接子目录(后端额外 `EXISTS` 查询,可为空目录为 `false`) | +- **空结果**: 不返回 404,空目录列表即 `directories: []`。 +- **错误**: 401(未登录)/ 403(非 workspace 成员)同其他接口。 + ### 3.4 `GET /api/v1/scripts` 列出当前 workspace 内**全部 active 脚本**。不受 is_locked 影响(读路径不锁)。 diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 58952ec..9647ed6 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -623,6 +623,7 @@ async def get_workspace_tree( select(StorageObjects.relative_path, StorageObjects.object_type).where( StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.object_status == "available", + StorageObjects.is_deleted == 0, StorageObjects.relative_path.like(like_prefix), ) ) @@ -664,6 +665,74 @@ async def get_workspace_tree( }, ) sorted_dirs = sorted(directories.values(), key=lambda item: item["path"]) + return { + "request_id": context.request_id, + "data": {"directories": sorted_dirs}, + "meta": {"directory_count": len(sorted_dirs)}, + } + + +@router.get("/api/v1/workspace-directories") +async def list_workspace_directories( + parent_path: str = Query(default=""), + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List direct child directories of a workspace path. + + Empty ``parent_path`` returns the directories immediately under the + user's scoped root. Only available, non-deleted StorageObjects are + considered. + """ + scoped_prefix = user_relative_path(context) + parent = normalize_user_path(parent_path) + target_prefix = f"{scoped_prefix}/{parent}" if parent else scoped_prefix + descendant_prefix = f"{target_prefix}/" + + rows = ( + await session.execute( + select(StorageObjects.relative_path, StorageObjects.object_type).where( + StorageObjects.workspace_id == context.workspace.workspace_id, + StorageObjects.object_status == "available", + StorageObjects.is_deleted == 0, + StorageObjects.relative_path.like(f"{descendant_prefix}%"), + ~StorageObjects.relative_path.like(f"{descendant_prefix}%/%"), + ) + ) + ).all() + + directories: dict[str, dict[str, Any]] = {} + for relative, _object_type in rows: + if not relative or not relative.startswith(descendant_prefix): + continue + suffix = relative[len(descendant_prefix) :] + if "/" in suffix: + continue + child_path = f"{parent}/{suffix}" if parent else suffix + directories.setdefault( + child_path, + { + "path": child_path, + "name": suffix, + "parent_path": parent, + "has_children": False, + }, + ) + + for directory in directories.values(): + child_prefix = f"{target_prefix}/{directory['path']}/" + has_children = await session.scalar( + select(StorageObjects.storage_object_id).where( + StorageObjects.workspace_id == context.workspace.workspace_id, + StorageObjects.object_status == "available", + StorageObjects.is_deleted == 0, + StorageObjects.relative_path.like(f"{child_prefix}%"), + ~StorageObjects.relative_path.like(f"{child_prefix}%/%"), + ).limit(1) + ) + directory["has_children"] = has_children is not None + + sorted_dirs = sorted(directories.values(), key=lambda item: item["path"]) return { "request_id": context.request_id, "data": {"directories": sorted_dirs}, @@ -1160,6 +1229,11 @@ async def delete_script( detail=exc.detail, ) from exc + if storage_object: + storage_object.object_status = "deleted" + storage_object.is_deleted = 1 + storage_object.deleted_at = datetime.now(UTC).replace(tzinfo=None) + script.status = "deleted" script.deleted_at = datetime.now(UTC).replace(tzinfo=None) return { diff --git a/migrations/versions/3ba4d8489f36_workspace_tree_relative_path_index.py b/migrations/versions/3ba4d8489f36_workspace_tree_relative_path_index.py new file mode 100644 index 0000000..c947d84 --- /dev/null +++ b/migrations/versions/3ba4d8489f36_workspace_tree_relative_path_index.py @@ -0,0 +1,38 @@ +"""Add workspace tree relative_path index. + +The ``list_workspace_directories`` endpoint filters by +``relative_path`` prefixes inside a workspace. A composite index on +``(workspace_id, relative_path(255))`` avoids scanning all rows for a +workspace when listing a subdirectory. + +Revision ID: 3ba4d8489f36 +Revises: f6a7b8c9d0e1 +Create Date: 2026-08-12 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "3ba4d8489f36" +down_revision: str | Sequence[str] | None = "f6a7b8c9d0e1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_index( + "idx_storage_workspace_relative_path", + "storage_objects", + [sa.text("`workspace_id`"), sa.text("`relative_path`(255)")], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "idx_storage_workspace_relative_path", + table_name="storage_objects", + )