From d68055a96cacc7b5850408360210aec9880c61a7 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:06:45 +0800 Subject: [PATCH] feat(scripts/resources): align list_scripts visibility with list_resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 69a9a48 把 data resources 的可见性从 list 恒真改成 workspace-wide + visibility 过滤 + admin 短路,但 scripts 端 没动。两端不对称,导致: * 非 admin 调 list_scripts 走 user_relative_path → workspace/{当前用户ID}/...,永远拿不到别人的脚本 * count_scripts 同样 user-scoped,dashboard "全部脚本" 只统计自己 * list_resources 响应没带 owner_display_name,data-only owner 的目录名回退到 userId.slice(-6),显示不友好 修复: * list_scripts / count_scripts 改 workspace-wide(新建 _build_list_scripts_workspace_descendant_prefix helper; 旧 _build_list_scripts_descendant_prefix 保留标 deprecated 避免破坏其它调用方);非 admin 追加 or_(owner_user_id = me, visibility in {workspace, public}) 与 list_resources 完全对称;admin 短路 * resource_payload 加 owner_display_name 字段(与 script_payload 对称);list_resources SELECT 加 Users.display_name + outerjoin * 前端 ScriptExplorer displayName 回退链:scripts 的 owner_display_name → data resources 的 owner_display_name → 本人 user.display_name → userId.slice(-6) 占位 ;ResourceItem 类型同步加 owner_display_name?: string|null 存储物理布局仍是 workspace/{user_id}/...,仅读取侧 listing/count 跨 owner。 --- backend/src/backend/resources.py | 13 +- backend/src/backend/scripts.py | 82 ++++++++++-- backend/tests/test_count_scripts.py | 81 +++++++++--- .../tests/test_list_scripts_parent_path.py | 120 ++++++++++++++++-- backend/tests/test_resources.py | 31 +++++ .../components/platform/ScriptExplorer.tsx | 6 +- frontend/app/services/api.ts | 1 + 7 files changed, 288 insertions(+), 46 deletions(-) diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index 92d5e6b..1ae4125 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -12,7 +12,7 @@ from datetime import UTC, datetime from pathlib import Path, PurePosixPath from typing import Any -from common.db.models import DataResources, StorageObjects +from common.db.models import DataResources, StorageObjects, Users from common.ids import new_ulid from common.storage import workspaces_root from common.storage.schemas import ( @@ -96,6 +96,7 @@ def resource_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 @@ -128,6 +129,7 @@ def resource_payload( "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, @@ -387,12 +389,13 @@ async def list_resources( keyword: str | None = Query(default=None, max_length=100), ) -> dict[str, Any]: statement = ( - select(DataResources, StorageObjects) + select(DataResources, StorageObjects, Users.display_name) .join( StorageObjects, StorageObjects.storage_object_id == DataResources.storage_object_id, ) + .outerjoin(Users, Users.user_id == DataResources.owner_user_id) .where( DataResources.workspace_id == context.workspace.workspace_id, DataResources.status == "active", @@ -445,8 +448,10 @@ async def list_resources( return { "request_id": context.request_id, "data": [ - resource_payload(resource, storage_object) - for resource, storage_object in rows + resource_payload( + resource, storage_object, owner_display_name=owner_display_name + ) + for resource, storage_object, owner_display_name in rows ], "meta": {"count": len(rows)}, } diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index aaabd7a..6218779 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -36,7 +36,7 @@ from fastapi import ( status, ) from loguru import logger -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from backend.dependencies import ( @@ -129,11 +129,21 @@ def _escape_like_pattern(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") +# @deprecated — legacy user-scoped prefix helper. list_scripts/count_scripts +# are now workspace-wide via _build_list_scripts_workspace_descendant_prefix; +# this helper is kept (with its original user-scoped semantics) so existing +# callers/tests that still reference it do not break. def _build_list_scripts_descendant_prefix( context: RequestContext, parent_path: str ) -> str: """Return the escaped materialized-path prefix for direct children of - ``parent_path``. + ``parent_path`` within the **requester's own subtree**. + + .. note:: + Legacy user-scoped helper. list_scripts / count_scripts are now + workspace-wide — use + :func:`_build_list_scripts_workspace_descendant_prefix` instead + (visibility filtering handles non-admin scoping in the SQL). The endpoint appends ``LIKE '/%' AND NOT LIKE '/%/%'`` against ``storage_objects.relative_path`` so only scripts whose parent @@ -156,6 +166,29 @@ def _build_list_scripts_descendant_prefix( return f"{_escape_like_pattern(target_prefix)}/" +def _build_list_scripts_workspace_descendant_prefix(parent_path: str) -> str: + """Return the escaped materialized-path prefix for direct children of + ``parent_path`` across **all owners** in the workspace. + + Storage is still physically laid out as ``workspace/{user_id}/...``, but + listing is workspace-wide: the prefix starts at ``workspace/`` (no + embedded user_id) so the endpoint's ``LIKE '/%'`` walks every + owner's subtree. Non-admin scoping is handled separately in the SQL via + ``owner_user_id = me OR visibility IN (workspace, public)``. + + Empty ``parent_path`` returns ``"workspace/"`` (cross-owner root). + The prefix is run through ``_escape_like_pattern`` so folder names + containing ``_`` / ``%`` do not act as wildcards. The trailing ``/`` + is appended AFTER escaping so it remains a literal slash. + """ + normalized_parent = normalize_user_path(parent_path) + if normalized_parent: + target_prefix = f"workspace/{normalized_parent}" + else: + target_prefix = "workspace" + return f"{_escape_like_pattern(target_prefix)}/" + + def safe_script_name(value: str, script_type: str) -> str: name = value.replace("\\", "/").rsplit("/", 1)[-1].strip() if not name or name in {".", ".."} or any(ord(char) < 32 for char in name): @@ -1106,7 +1139,12 @@ async def list_scripts( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - descendant_prefix = _build_list_scripts_descendant_prefix(context, parent_path) + # Workspace-wide listing: storage is physically laid out as + # ``workspace/{user_id}/...``, but the prefix starts at ``workspace/`` + # (no embedded user_id) so the LIKE walks every owner's subtree. + # Non-admin scoping is applied below via visibility, matching + # list_resources (69a9a48). + descendant_prefix = _build_list_scripts_workspace_descendant_prefix(parent_path) statement = ( select(Scripts, StorageObjects, Users.display_name) @@ -1123,6 +1161,16 @@ async def list_scripts( ) .order_by(Scripts.updated_at.desc()) ) + # 只返回 owner 自己的脚本(含 private),或 visibility 为 + # workspace/public 的其他成员脚本;A 的 private 脚本对非 owner 不可见。 + # admin 跳过过滤,全部可见。 + if not context.is_admin: + statement = statement.where( + or_( + Scripts.owner_user_id == context.user.user_id, + Scripts.visibility.in_(["workspace", "public"]), + ) + ) rows = (await session.execute(statement)).all() return { "request_id": context.request_id, @@ -1138,25 +1186,25 @@ async def list_scripts( # 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明 # ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。 # -# Scope: equals the UNION of list_scripts across every parent_path -# within the user's subtree, NOT just the root-level call. We -# intentionally include deeper descendants because the dashboard's -# "全部脚本" / "工作副本" counts reflect the whole workspace, not -# only the top level. +# Scope: workspace-wide — the dashboard's "全部脚本" / "工作副本" counts +# reflect the whole workspace, not the requester's own subtree. admin sees +# every active script; non-admin is narrowed by visibility +# (owner_user_id = me OR visibility IN (workspace, public)), exactly like +# list_scripts and list_resources. # # Implementation choices and why: # - INNER JOIN to StorageObjects so orphans (current_object_id has no # joinable row) are excluded. -# - User subtree filter so multi-member workspaces don't show counts -# the requester can't see. +# - Workspace-wide ``LIKE 'workspace/%'`` prefix (no embedded user_id) so +# counts span every owner's subtree. # - No NOT-LIKE filter because the count wants descendants too. @router.get("/api/v1/scripts/count") async def count_scripts( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - user_subtree_prefix = f"{_escape_like_pattern(user_relative_path(context))}/%" - total = await session.scalar( + descendant_prefix = _build_list_scripts_workspace_descendant_prefix("") + base = ( select(func.count()) .select_from(Scripts) .join( @@ -1166,9 +1214,17 @@ async def count_scripts( .where( Scripts.workspace_id == context.workspace.workspace_id, Scripts.status == "active", - StorageObjects.relative_path.like(user_subtree_prefix, escape="\\"), + StorageObjects.relative_path.like(f"{descendant_prefix}%", escape="\\"), ) ) + if not context.is_admin: + base = base.where( + or_( + Scripts.owner_user_id == context.user.user_id, + Scripts.visibility.in_(["workspace", "public"]), + ) + ) + total = await session.scalar(base) return { "request_id": context.request_id, "data": {"total": int(total or 0)}, diff --git a/backend/tests/test_count_scripts.py b/backend/tests/test_count_scripts.py index 31cd965..9c4a4bc 100644 --- a/backend/tests/test_count_scripts.py +++ b/backend/tests/test_count_scripts.py @@ -1,10 +1,11 @@ """Unit tests for GET /api/v1/scripts/count endpoint. -Verifies the count endpoint returns the same scope as -``list_scripts(parent_path="")``: workspace + active scripts whose -``StorageObjects.relative_path`` lives under the user's subtree. This -avoids under/over-reporting on the dashboard — the count is the size of -the set list_scripts would return if it weren't lazy. +Verifies the count endpoint matches the (workspace-wide) listing scope of +``list_scripts(parent_path="")``: workspace + active scripts across every +owner's ``StorageObjects.relative_path``, narrowed by visibility for +non-admin (admin short-circuits). This avoids under/over-reporting on the +dashboard — the count is the size of the set list_scripts would return if +it weren't lazy. """ from __future__ import annotations @@ -17,13 +18,23 @@ import pytest from backend.scripts import count_scripts -def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace: +def _ctx( + user_id: str = "U001", + workspace_id: str = "W001", + *, + is_admin: bool = False, + is_system_admin: bool = False, +) -> SimpleNamespace: return SimpleNamespace( request_id="test", user=SimpleNamespace(user_id=user_id), workspace=SimpleNamespace(workspace_id=workspace_id), - role=SimpleNamespace(role_code="admin"), - is_system_admin=False, + role=SimpleNamespace(role_code="admin" if is_admin else "developer"), + is_system_admin=is_system_admin, + # ``count_scripts`` now consults ``context.is_admin`` directly + # (matching list_scripts / list_resources); SimpleNamespace needs + # it as a plain attribute. + is_admin=is_admin or is_system_admin, ) @@ -57,10 +68,13 @@ async def test_count_scripts_returns_scalar_int() -> None: # JOIN to StorageObjects so orphaned scripts (no joinable row) are # excluded — matches list_scripts INNER JOIN behaviour. assert "inner join storage_objects" in sql - # Scope: workspace_id + active status + user subtree. + # Scope: workspace_id + active status + workspace-wide prefix. assert "scripts.workspace_id" in sql assert "scripts.status" in sql - assert "workspace/u001/%" in sql + assert "like 'workspace/%%'" in sql + # Non-admin (default) narrows by visibility. + assert "scripts.owner_user_id = 'u001'" in sql + assert "scripts.visibility in ('workspace', 'public')" in sql async def test_count_scripts_handles_null_result() -> None: @@ -72,10 +86,10 @@ async def test_count_scripts_handles_null_result() -> None: assert result["data"] == {"total": 0} -async def test_count_scripts_uses_user_specific_subtree() -> None: - """Different users in the same workspace must see different totals — - each user's count is bounded by their own ``workspace/{user_id}/`` - subtree, NOT the whole workspace.""" +async def test_count_scripts_workspace_wide_not_user_scoped() -> None: + """The prefix is workspace-wide (``workspace/%`` — no embedded user_id), + so different users count the same physical tree; the only per-user + difference is the non-admin visibility predicate (owner_user_id = me).""" captured = [] mock_session = MagicMock() @@ -84,14 +98,41 @@ async def test_count_scripts_uses_user_specific_subtree() -> None: ) await count_scripts(context=_ctx(user_id="alice"), session=mock_session) - sql_alice = _compile(captured[-1]) + sql_alice = _compile(captured[-1]).lower() await count_scripts(context=_ctx(user_id="bob"), session=mock_session) - sql_bob = _compile(captured[-1]) + sql_bob = _compile(captured[-1]).lower() - assert "workspace/alice/%" in sql_alice - assert "workspace/alice/%" not in sql_bob - assert "workspace/bob/%" in sql_bob + # Both count the same workspace-wide subtree. + assert "like 'workspace/%%'" in sql_alice + assert "like 'workspace/%%'" in sql_bob + # Neither embeds the user_id in the path prefix. + assert "workspace/alice/%" not in sql_alice + assert "workspace/bob/%" not in sql_bob + # Per-user narrowing happens via the visibility predicate. + assert "scripts.owner_user_id = 'alice'" in sql_alice + assert "scripts.owner_user_id = 'bob'" in sql_bob + + +async def test_count_scripts_admin_skips_visibility_filter() -> None: + """Admin short-circuits the visibility predicate and counts every + active script in the workspace (dashboard '全部脚本' / '工作副本').""" + captured = [] + + mock_session = MagicMock() + mock_session.scalar = AsyncMock( + side_effect=lambda stmt: (captured.append(stmt), 42)[1] + ) + + result = await count_scripts( + context=_ctx(user_id="alice", is_admin=True), session=mock_session + ) + assert result["data"] == {"total": 42} + sql = _compile(captured[0]).lower() + assert "like 'workspace/%%'" in sql + # visibility / owner_user_id still appear in the SELECT projection, but + # the visibility WHERE predicate must be absent for admins. + assert "scripts.visibility in ('workspace', 'public')" not in sql async def test_count_scripts_route_declared_before_script_id_route() -> None: @@ -101,4 +142,4 @@ async def test_count_scripts_route_declared_before_script_id_route() -> None: from backend.scripts import count_scripts, get_script assert callable(count_scripts) - assert callable(get_script) \ No newline at end of file + assert callable(get_script) diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py index 7074b15..5e02c67 100644 --- a/backend/tests/test_list_scripts_parent_path.py +++ b/backend/tests/test_list_scripts_parent_path.py @@ -29,18 +29,24 @@ from sqlalchemy.dialects import mysql as mysql_dialect from backend.scripts import ( _build_list_scripts_descendant_prefix, + _build_list_scripts_workspace_descendant_prefix, _escape_like_pattern, normalize_user_path, ) -def _ctx(user_id: str = "U001") -> SimpleNamespace: +def _ctx( + user_id: str = "U001", *, is_admin: bool = False, is_system_admin: bool = False +) -> SimpleNamespace: return SimpleNamespace( request_id="test", user=SimpleNamespace(user_id=user_id), workspace=SimpleNamespace(workspace_id="W001"), - role=SimpleNamespace(role_code="admin"), - is_system_admin=False, + role=SimpleNamespace(role_code="admin" if is_admin else "developer"), + is_system_admin=is_system_admin, + # ``list_scripts`` now consults ``context.is_admin`` directly (matching + # ``list_resources``); SimpleNamespace needs it as a plain attribute. + is_admin=is_admin or is_system_admin, ) @@ -115,6 +121,51 @@ def test_normalize_user_path_strips() -> None: assert normalize_user_path("a\\b") == "a/b" +# ─── layer 1.6: workspace-wide prefix helper ───────────────────── + + +def test_workspace_descendant_prefix_root() -> None: + """Empty parent_path → workspace-wide root prefix (cross-owner).""" + assert _build_list_scripts_workspace_descendant_prefix("") == "workspace/" + + +def test_workspace_descendant_prefix_subdir() -> None: + """Non-empty parent_path → appended under workspace root, no user_id.""" + assert ( + _build_list_scripts_workspace_descendant_prefix("foo/bar") + == "workspace/foo/bar/" + ) + + +def test_workspace_descendant_prefix_escapes_metachars() -> None: + r"""Folder ``foo_bar`` must produce ``foo\_bar`` so the trailing ``%`` + doesn't become 'match any single char'.""" + assert ( + _build_list_scripts_workspace_descendant_prefix("foo_bar") + == r"workspace/foo\_bar/" + ) + + +def test_workspace_descendant_prefix_escapes_percent() -> None: + assert ( + _build_list_scripts_workspace_descendant_prefix("100%match") + == r"workspace/100\%match/" + ) + + +def test_workspace_descendant_prefix_normalizes_leading_trailing_slashes() -> None: + assert ( + _build_list_scripts_workspace_descendant_prefix("/foo/bar/") + == "workspace/foo/bar/" + ) + + +def test_workspace_descendant_prefix_rejects_traversal() -> None: + with pytest.raises(HTTPException) as exc: + _build_list_scripts_workspace_descendant_prefix("foo/../bar") + assert exc.value.status_code == 422 + + # ─── layer 2: SQL contract ──────────────────────────────────────── @@ -147,8 +198,8 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() assert len(captured_sql) == 1 sql = captured_sql[0].lower() - assert "like 'workspace/alice/foo/bar/%%'" in sql - assert "not like 'workspace/alice/foo/bar/%%/%%'" in sql + assert "like 'workspace/foo/bar/%%'" in sql + assert "not like 'workspace/foo/bar/%%/%%'" in sql async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: @@ -177,9 +228,9 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: # doubles the escape char inside the SQL string literal, so what # the helper emits as `foo\_bar` renders as `foo\\_bar` here # (2 backslash chars in the actual SQL string). - assert r"like 'workspace/alice/foo\\_bar/%%'" in sql_lower + assert r"like 'workspace/foo\\_bar/%%'" in sql_lower # NOT LIKE clause also escaped. - assert r"not like 'workspace/alice/foo\\_bar/%%/%%'" in sql_lower + assert r"not like 'workspace/foo\\_bar/%%/%%'" in sql_lower # And both declare ESCAPE '\\'. assert sql.count("ESCAPE '\\\\'") == 2, sql @@ -206,7 +257,60 @@ async def test_list_scripts_where_clause_escapes_percent_pattern() -> None: sql_lower = sql.lower() # SQLAlchemy doubles the escape char so `%` → `\%` becomes `\\%` # in the SQL string literal. - assert r"workspace/alice/100\\%%match/%%" in sql_lower + assert r"workspace/100\\%%match/%%" in sql_lower + + +async def test_list_scripts_non_admin_adds_visibility_filter() -> None: + """Workspace-wide listing is narrowed by visibility for non-admin: + owner_user_id = me OR visibility IN (workspace, public) — exactly like + list_resources. The workspace prefix contains NO user_id (cross-owner).""" + from backend.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append(_compile_sql(stmt)) or _MockResult() + ) + ) + + await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session) + sql = captured_sql[0].lower() + assert "like 'workspace/%%'" in sql + assert "scripts.owner_user_id = 'alice'" in sql + assert "scripts.visibility in ('workspace', 'public')" in sql + + +async def test_list_scripts_admin_skips_visibility_filter() -> None: + """Admin short-circuits the visibility predicate and sees everything.""" + from backend.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append(_compile_sql(stmt)) or _MockResult() + ) + ) + + await list_scripts( + parent_path="", context=_ctx("alice", is_admin=True), session=mock_session + ) + sql = captured_sql[0].lower() + assert "like 'workspace/%%'" in sql + # owner_user_id / visibility still appear in the SELECT projection; what + # must be absent is the visibility WHERE predicate for non-admins. + assert "scripts.visibility in ('workspace', 'public')" not in sql async def test_list_workspace_directories_where_clause_escapes_pattern() -> None: diff --git a/backend/tests/test_resources.py b/backend/tests/test_resources.py index 22d3d58..ae51739 100644 --- a/backend/tests/test_resources.py +++ b/backend/tests/test_resources.py @@ -322,6 +322,8 @@ def test_resource_payload_legacy_dot_resources(): ) assert payload["jupyter_accessible_path"] == ".resources/data.csv" assert payload["absolute_path"].endswith(f"{ws}/{user}/.resources/data.csv") + # Default None when no Users join row is provided (bind_resource path). + assert payload["owner_display_name"] is None def test_resource_payload_new_flat_path(): @@ -333,6 +335,13 @@ def test_resource_payload_new_flat_path(): ) assert payload["jupyter_accessible_path"] == "data.csv" assert payload["absolute_path"].endswith(f"{ws}/{user}/data.csv") + # Explicit owner_display_name is passed through to the payload. + payload = resource_payload( + _make_resource(ws, user), + _make_storage_object(f"{ws}/{user}/data.csv"), + owner_display_name="张三", + ) + assert payload["owner_display_name"] == "张三" def test_resource_payload_new_nested_path(): @@ -344,6 +353,7 @@ def test_resource_payload_new_nested_path(): ) assert payload["jupyter_accessible_path"] == "train/v1/data.csv" assert payload["absolute_path"].endswith(f"{ws}/{user}/train/v1/data.csv") + assert payload["owner_display_name"] is None def test_compute_jupyter_relative_path_for_legacy_and_new_paths(): @@ -621,6 +631,27 @@ async def test_list_resources_without_parent_path_adds_no_like_clause() -> None: assert " not like " not in sql +async def test_list_resources_joins_users_for_display_name() -> None: + """list_resources must OUTER JOIN users and SELECT users.display_name so + every resource carries owner_display_name (frontend displayName chain).""" + from backend.resources import list_resources + + captured_sql: list[str] = [] + mock_session = _list_resources_capturing_session(captured_sql) + + await list_resources( + parent_path="", + context=_resource_ctx(), + session=mock_session, + visibility=None, + keyword=None, + ) + sql = captured_sql[0] + sql_lower = sql.lower() + assert "outer join users" in sql_lower + assert "users.display_name" in sql_lower + + # ─── layer 3: behavioral test on real LIKE execution (SQLite) ──────────────── diff --git a/frontend/app/components/platform/ScriptExplorer.tsx b/frontend/app/components/platform/ScriptExplorer.tsx index d811d44..ad9e5a4 100644 --- a/frontend/app/components/platform/ScriptExplorer.tsx +++ b/frontend/app/components/platform/ScriptExplorer.tsx @@ -109,8 +109,12 @@ export function ScriptExplorer({ dataResources: ResourceItem[]; }[] = []; for (const [ownerUserId, groupScripts] of byOwner.entries()) { + const groupDataResources = dataByOwner.get(ownerUserId) ?? []; + // data-only owner(没有 scripts 的用户)回退到 data resources 的 + // owner_display_name,否则 data-only owner 前端只能显示 userId 末 6 位。 const displayName = groupScripts[0]?.owner_display_name ?? + groupDataResources[0]?.owner_display_name ?? (ownerUserId === user?.user_id ? user?.display_name : null) ?? `${ownerUserId.slice(-6)}…`; const groupUser = @@ -133,7 +137,7 @@ export function ScriptExplorer({ ownerUserId === user?.user_id ? mergeDirectories(directories, inferred) : inferred, - dataResources: dataByOwner.get(groupUser.user_id) ?? [], + dataResources: groupDataResources, }); } diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index c0ac622..e4063ce 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -417,6 +417,7 @@ export type ResourceItem = { workspace_id: string; storage_object_id: string; owner_user_id: string; + owner_display_name?: string | null; resource_name: string; description: string | null; visibility: "private" | "workspace" | "public";