update: workspace lazy load
This commit is contained in:
@@ -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 '<prefix>/%' AND relative_path NOT LIKE '<prefix>/%/%'`,其中 `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 影响(读路径不锁)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user