feat:脚本文件预览-backend

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 0aca4934f7
commit 807870e90d
5 changed files with 374 additions and 167 deletions
+2
View File
@@ -508,6 +508,8 @@ queued ──→ running ──┬─→ succeeded
| `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 | | `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 |
| `GET` | `/api/v1/data-resources` | 列表(owner 作用域 + visibility 过滤) | | `GET` | `/api/v1/data-resources` | 列表(owner 作用域 + visibility 过滤) |
| `GET` | `/api/v1/data-resources/{id}` | 详情 | | `GET` | `/api/v1/data-resources/{id}` | 详情 |
| `GET` | `/api/v1/data-resources/{id}/content` | 同源流式读取文件字节(预览/下载) |
| `GET` | `/api/v1/data-resources/{id}/preview` | 表格抽样预览(csv/tsv, `limit` 默认 100) |
| `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL | | `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL |
| `DELETE` | `/api/v1/data-resources/{id}` | 软删 | | `DELETE` | `/api/v1/data-resources/{id}` | 软删 |
@@ -0,0 +1,130 @@
"""数据资源路由共享的 payload / 可见性辅助。"""
from __future__ import annotations
import os
from pathlib import Path, PurePosixPath
from typing import Any
from common.db.models import DataResources, StorageObjects
from common.storage import workspaces_root
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import RequestContext
from backend.api.scripts import _escape_like_pattern, normalize_user_path
def build_list_resources_descendant_prefix(parent_path: str) -> str:
"""Return the escaped materialized-path prefix for direct children."""
normalized = normalize_user_path(parent_path)
escaped = _escape_like_pattern(normalized)
return f"{escaped}/" if escaped else ""
def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str:
"""从当前脚本所在目录算到资源文件的 Jupyter 相对路径。"""
script_dir = PurePosixPath(script_path).parent.as_posix()
if not script_dir or script_dir == ".":
return resource_relative
return os.path.relpath(resource_relative, start=script_dir)
def resource_directory(
object_key: str,
workspace_id: str,
owner_user_id: str,
) -> str:
"""从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。"""
prefix = f"{workspace_id}/{owner_user_id}/"
if not object_key.startswith(prefix):
return ""
tail = object_key[len(prefix):]
directory, _, _ = tail.rpartition("/")
return directory
def resource_payload(
resource: DataResources,
storage_object: StorageObjects,
owner_display_name: str | None = None,
) -> dict[str, Any]:
workspace_prefix = f"{resource.workspace_id}/"
user_prefix = f"{resource.owner_user_id}/"
jupyter_accessible_path = ""
absolute_path = ""
if storage_object.object_key and storage_object.object_key.startswith(
workspace_prefix
):
remainder = storage_object.object_key[len(workspace_prefix):]
if remainder.startswith(user_prefix):
tail = remainder[len(user_prefix):]
jupyter_accessible_path = tail
absolute_path = (
workspaces_root()
/ resource.workspace_id
/ resource.owner_user_id
/ Path(tail)
).as_posix()
else:
jupyter_accessible_path = remainder
elif storage_object.object_key:
jupyter_accessible_path = storage_object.object_key
return {
"resource_id": resource.resource_id,
"workspace_id": resource.workspace_id,
"storage_object_id": resource.storage_object_id,
"owner_user_id": resource.owner_user_id,
"owner_display_name": owner_display_name,
"resource_name": resource.resource_name,
"description": resource.description,
"visibility": resource.visibility,
"status": resource.status,
"created_at": resource.created_at.isoformat(),
"updated_at": resource.updated_at.isoformat(),
"file": {
"file_name": storage_object.file_name,
"file_extension": storage_object.file_extension,
"mime_type": storage_object.mime_type,
"size_bytes": storage_object.size_bytes,
"content_hash": storage_object.content_hash,
"object_status": storage_object.object_status,
},
"jupyter_accessible_path": jupyter_accessible_path,
"absolute_path": absolute_path,
}
def can_view(resource: DataResources, context: RequestContext) -> bool:
if resource.owner_user_id == context.user.user_id:
return True
if resource.visibility in {"workspace", "public"}:
return True
return context.is_admin
async def get_visible_resource(
resource_id: str,
context: RequestContext,
session: AsyncSession,
) -> tuple[DataResources, StorageObjects]:
row = (
await session.execute(
select(DataResources, StorageObjects)
.join(
StorageObjects,
StorageObjects.storage_object_id
== DataResources.storage_object_id,
)
.where(
DataResources.resource_id == resource_id,
DataResources.workspace_id
== context.workspace.workspace_id,
DataResources.status == "active",
)
)
).one_or_none()
if row is None or not can_view(row[0], context):
raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found")
return row
+9 -167
View File
@@ -7,17 +7,13 @@
from __future__ import annotations from __future__ import annotations
import os
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
from common.db.models import DataResources, StorageObjects, Users from common.db.models import DataResources, StorageObjects, Users
from common.ids import new_ulid from common.ids import new_ulid
from common.storage import workspaces_root
from common.storage.schemas import ( from common.storage.schemas import (
CreateUploadRequest, CreateUploadRequest,
DownloadUrlRequest,
) )
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@@ -28,8 +24,15 @@ from backend.api.dependencies import (
database_session, database_session,
request_context, request_context,
) )
from backend.api.scripts import _escape_like_pattern, normalize_user_path from backend.api._resources_common import (
from backend.schemas.common import DownloadUrlRequest build_list_resources_descendant_prefix as _build_list_resources_descendant_prefix,
can_view,
compute_jupyter_relative_path,
get_visible_resource,
resource_directory,
resource_payload,
)
from backend.api.scripts import _escape_like_pattern
from backend.schemas.resources import ( from backend.schemas.resources import (
CompleteResourceUploadRequest, CompleteResourceUploadRequest,
CreateResourceUploadRequest, CreateResourceUploadRequest,
@@ -37,7 +40,6 @@ from backend.schemas.resources import (
) )
from backend.services.storage import ( from backend.services.storage import (
acquire_named_lock, acquire_named_lock,
create_download_url_payload,
create_upload_record, create_upload_record,
release_named_lock, release_named_lock,
soft_delete_object, soft_delete_object,
@@ -47,119 +49,6 @@ from backend.services.storage import (
router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"]) router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
def _build_list_resources_descendant_prefix(parent_path: str) -> str:
"""Return the escaped materialized-path prefix for direct children
of ``parent_path`` against ``StorageObjects.object_key``.
The full object_key is ``{ws_id}/{owner_user_id}/{jupyter_path}``.
``list_resources`` prepends ``{ws_id}/{owner_user_id}`` (the requester
by default, or the ``owner_user_id`` query param) to this prefix and
applies ``LIKE '{ws_id}/{owner}/{prefix}%' AND NOT LIKE '...%/%'`` so
only that owner's direct children under ``parent_path`` match. LIKE
wildcards in parent_path are escaped so folder names containing ``_``
or ``%`` do not act as wildcards.
"""
normalized = normalize_user_path(parent_path)
escaped = _escape_like_pattern(normalized)
return f"{escaped}/" if escaped else ""
def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str:
"""从当前脚本所在目录算到资源文件的 Jupyter 相对路径。
script_path / resource_relative 都是相对于 user root_dir 的 POSIX 路径。
"""
script_dir = PurePosixPath(script_path).parent.as_posix()
if not script_dir or script_dir == ".":
return resource_relative
return os.path.relpath(resource_relative, start=script_dir)
def resource_directory(
object_key: str,
workspace_id: str,
owner_user_id: str,
) -> str:
"""从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。
object_key 形如 ``{ws_id}/{user_id}/{target_path}/{file_name}``;
不匹配该前缀的键(如无 ws/user 前缀的旧数据)统一视为根目录。
"""
prefix = f"{workspace_id}/{owner_user_id}/"
if not object_key.startswith(prefix):
return ""
tail = object_key[len(prefix):]
directory, _, _ = tail.rpartition("/")
return directory
def resource_payload(
resource: DataResources,
storage_object: StorageObjects,
owner_display_name: str | None = None,
) -> dict[str, Any]:
# ``object_key`` now follows ``{ws_id}/{user_id}/{target_path}/{file_name}``
# (target_path may be empty). Legacy objects still live under
# ``.resources/{file_name}`` and must remain readable. Both shapes share
# the same derivation: strip the workspace/user prefix and use the rest
# as the Jupyter-relative path.
workspace_prefix = f"{resource.workspace_id}/"
user_prefix = f"{resource.owner_user_id}/"
jupyter_accessible_path = ""
absolute_path = ""
if storage_object.object_key and storage_object.object_key.startswith(
workspace_prefix
):
remainder = storage_object.object_key[len(workspace_prefix):]
if remainder.startswith(user_prefix):
tail = remainder[len(user_prefix):]
jupyter_accessible_path = tail
absolute_path = (
workspaces_root()
/ resource.workspace_id
/ resource.owner_user_id
/ Path(tail)
).as_posix()
else:
jupyter_accessible_path = remainder
elif storage_object.object_key:
jupyter_accessible_path = storage_object.object_key
return {
"resource_id": resource.resource_id,
"workspace_id": resource.workspace_id,
"storage_object_id": resource.storage_object_id,
"owner_user_id": resource.owner_user_id,
"owner_display_name": owner_display_name,
"resource_name": resource.resource_name,
"description": resource.description,
"visibility": resource.visibility,
"status": resource.status,
"created_at": resource.created_at.isoformat(),
"updated_at": resource.updated_at.isoformat(),
"file": {
"file_name": storage_object.file_name,
"file_extension": storage_object.file_extension,
"mime_type": storage_object.mime_type,
"size_bytes": storage_object.size_bytes,
"content_hash": storage_object.content_hash,
"object_status": storage_object.object_status,
},
"jupyter_accessible_path": jupyter_accessible_path,
"absolute_path": absolute_path,
}
def can_view(resource: DataResources, context: RequestContext) -> bool:
# 同一 workspace 内:owner 永远可见自己的资源(含 private);
# 其他成员只见 visibility in {workspace, public} 的资源;
# admin 全部可见。
if resource.owner_user_id == context.user.user_id:
return True
if resource.visibility in {"workspace", "public"}:
return True
return context.is_admin
# 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。 # 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。
@router.post("/{resource_id}/jupyter-relative-path") @router.post("/{resource_id}/jupyter-relative-path")
async def resource_jupyter_relative_path( async def resource_jupyter_relative_path(
@@ -465,31 +354,6 @@ async def list_resources(
} }
async def get_visible_resource(
resource_id: str,
context: RequestContext,
session: AsyncSession,
) -> tuple[DataResources, StorageObjects]:
row = (
await session.execute(
select(DataResources, StorageObjects)
.join(
StorageObjects,
StorageObjects.storage_object_id
== DataResources.storage_object_id,
)
.where(
DataResources.resource_id == resource_id,
DataResources.workspace_id
== context.workspace.workspace_id,
DataResources.status == "active",
)
)
).one_or_none()
if row is None or not can_view(row[0], context):
raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found")
return row
# 查询单个数据资源的元数据与其关联文件信息。 # 查询单个数据资源的元数据与其关联文件信息。
@router.get("/{resource_id}") @router.get("/{resource_id}")
@@ -510,28 +374,6 @@ async def get_resource(
} }
# 为资源文件生成带时效的下载链接。
@router.post("/{resource_id}/download-url")
async def resource_download_url(
resource_id: str,
payload: DownloadUrlRequest,
request: Request,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
resource, _ = await get_visible_resource(
resource_id,
context,
session,
)
data = await create_download_url_payload(
await session.get(StorageObjects, resource.storage_object_id),
DownloadUrlRequest(expires_seconds=payload.expires_seconds),
request,
)
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
# 软删除数据资源及其关联对象,遵循存储层的回收站策略。 # 软删除数据资源及其关联对象,遵循存储层的回收站策略。
@router.delete("/{resource_id}") @router.delete("/{resource_id}")
async def delete_resource( async def delete_resource(
@@ -0,0 +1,231 @@
"""数据资源下载、同源内容流与表格抽样预览接口。
与 ``resources.py`` 共用前缀 ``/api/v1/data-resources``,在 ``main`` 中并列挂载。
"""
from __future__ import annotations
import csv
import io
from typing import Any
from urllib.parse import quote
from common.db.models import StorageObjects
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.api._resources_common import get_visible_resource
from backend.schemas.common import DownloadUrlRequest
from backend.services.storage import create_download_url_payload
router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
_PREVIEW_MAX_BYTES = 2 * 1024 * 1024
_PREVIEW_DEFAULT_LIMIT = 100
_PREVIEW_MAX_LIMIT = 500
_TABLE_EXTENSIONS = {".csv", ".tsv"}
def _extension_of(file_name: str | None, resource_name: str | None) -> str:
for candidate in (file_name, resource_name):
if not candidate:
continue
lower = candidate.lower()
for ext in _TABLE_EXTENSIONS:
if lower.endswith(ext):
return ext
return ""
def _decode_preview_text(raw: bytes) -> str:
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace")
async def _read_prefix_bytes(store: Any, object_key: str, max_bytes: int) -> bytes:
chunks: list[bytes] = []
total = 0
async for chunk in store.get_stream(object_key):
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if total >= max_bytes:
break
data = b"".join(chunks)
return data[:max_bytes]
def _parse_table_preview(
text: str,
*,
delimiter: str,
limit: int,
byte_truncated: bool,
) -> dict[str, Any]:
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
try:
header = next(reader)
except StopIteration:
return {
"kind": "table",
"columns": [],
"rows": [],
"row_count": 0,
"truncated": byte_truncated,
"delimiter": delimiter,
}
columns = [str(cell) if cell is not None else "" for cell in header]
if not any(columns):
columns = [f"col_{index + 1}" for index in range(max(len(header), 1))]
rows: list[list[str]] = []
truncated = byte_truncated
for row in reader:
if len(rows) >= limit:
truncated = True
break
cells = [str(cell) if cell is not None else "" for cell in row]
if len(cells) < len(columns):
cells.extend([""] * (len(columns) - len(cells)))
elif len(cells) > len(columns):
cells = cells[: len(columns)]
rows.append(cells)
return {
"kind": "table",
"columns": columns,
"rows": rows,
"row_count": len(rows),
"truncated": truncated,
"delimiter": delimiter,
}
@router.post("/{resource_id}/download-url")
async def resource_download_url(
resource_id: str,
payload: DownloadUrlRequest,
request: Request,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
resource, _ = await get_visible_resource(
resource_id,
context,
session,
)
data = await create_download_url_payload(
await session.get(StorageObjects, resource.storage_object_id),
DownloadUrlRequest(expires_seconds=payload.expires_seconds),
request,
)
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
@router.get("/{resource_id}/content")
async def resource_content(
resource_id: str,
request: Request,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> StreamingResponse:
"""同源流式读取资源字节,供前端预览器加载。"""
resource, storage_object = await get_visible_resource(
resource_id,
context,
session,
)
if storage_object.object_status != "available":
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
if (
not storage_object.bucket_name
or not storage_object.object_key
or storage_object.bucket_name not in request.app.state.object_stores
):
raise HTTPException(
status.HTTP_409_CONFLICT,
"object does not support content download",
)
file_name = storage_object.file_name or resource.resource_name or "file"
media_type = storage_object.mime_type or "application/octet-stream"
store = request.app.state.object_stores[storage_object.bucket_name]
stream = store.get_stream(storage_object.object_key)
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{quote(file_name)}",
"Cache-Control": "private, no-store",
}
if storage_object.size_bytes is not None:
headers["Content-Length"] = str(storage_object.size_bytes)
return StreamingResponse(
stream,
media_type=media_type,
headers=headers,
)
@router.get("/{resource_id}/preview")
async def resource_preview(
resource_id: str,
request: Request,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
limit: int = Query(default=_PREVIEW_DEFAULT_LIMIT, ge=1, le=_PREVIEW_MAX_LIMIT),
) -> dict[str, Any]:
"""表格类数据资源抽样预览(csv / tsv)。"""
resource, storage_object = await get_visible_resource(
resource_id,
context,
session,
)
if storage_object.object_status != "available":
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
if (
not storage_object.bucket_name
or not storage_object.object_key
or storage_object.bucket_name not in request.app.state.object_stores
):
raise HTTPException(
status.HTTP_409_CONFLICT,
"object does not support preview",
)
extension = _extension_of(storage_object.file_name, resource.resource_name)
if extension not in _TABLE_EXTENSIONS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"仅支持预览 .csv / .tsv 表格文件",
)
store = request.app.state.object_stores[storage_object.bucket_name]
raw = await _read_prefix_bytes(
store,
storage_object.object_key,
_PREVIEW_MAX_BYTES,
)
byte_truncated = (
storage_object.size_bytes is not None
and storage_object.size_bytes > len(raw)
) or len(raw) >= _PREVIEW_MAX_BYTES
text = _decode_preview_text(raw)
delimiter = "\t" if extension == ".tsv" else ","
payload = _parse_table_preview(
text,
delimiter=delimiter,
limit=limit,
byte_truncated=byte_truncated,
)
return {"request_id": context.request_id, "data": payload, "meta": {}}
+2
View File
@@ -38,6 +38,7 @@ from backend.api.auth import router as auth_router
from backend.api.jupyter import router as jupyter_router from backend.api.jupyter import router as jupyter_router
from backend.api.platform import router as platform_router from backend.api.platform import router as platform_router
from backend.api.resources import router as resources_router from backend.api.resources import router as resources_router
from backend.api.resources_content import router as resources_content_router
from backend.api.schedules.runs import router as schedule_runs_router from backend.api.schedules.runs import router as schedule_runs_router
from backend.api.schedules.schedules import router as schedules_router from backend.api.schedules.schedules import router as schedules_router
from backend.api.scripts import router as scripts_router from backend.api.scripts import router as scripts_router
@@ -107,6 +108,7 @@ app = create_service_app(
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(jupyter_router) app.include_router(jupyter_router)
app.include_router(resources_router) app.include_router(resources_router)
app.include_router(resources_content_router)
app.include_router(schedule_runs_router) app.include_router(schedule_runs_router)
app.include_router(schedules_router) app.include_router(schedules_router)
app.include_router(scripts_router) app.include_router(scripts_router)