diff --git a/API.md b/API.md index 2e14b9d..ee4dd14 100644 --- a/API.md +++ b/API.md @@ -484,10 +484,14 @@ queued ──→ running ──┬─→ succeeded |---|---|---| | `POST` | `/api/v1/data-resources/uploads` | 创建上传会话,返回 `upload_id` + `upload_path` | | `PUT` | `/api/v1/data-resources/uploads/{upload_id}` | 上传字节(请求体即文件内容) | +| `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 | | `GET` | `/api/v1/data-resources` | 列表(workspace 范围) | | `GET` | `/api/v1/data-resources/{id}` | 详情 | | `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL | | `DELETE` | `/api/v1/data-resources/{id}` | 软删 | + +**同名冲突**:同一 `workspace` 内、同一目录(`target_path` 相等)、同一 `owner` 下,`resource_name` 重复提交绑定返回 `409`。不同目录或不同 `owner` 允许重名。重新绑定同一 `upload_id`(`storage_object_id` 已落库)走 idempotent 复用路径,不视为冲突。 + 字节归档到 trash bucket(`settings.s3_trash_bucket`)。 请求示例(上传):`POST /api/v1/data-resources/uploads` diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index b8114f8..cd2bcd2 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -48,6 +48,24 @@ def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> s 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, @@ -242,18 +260,41 @@ async def bind_resource( item.visibility = payload.visibility # (description lives on DataResources, not on StorageObjects.) - existing_active = await session.scalar( - select(DataResources).where( - DataResources.workspace_id == context.workspace.workspace_id, - DataResources.resource_name == payload.resource_name, - DataResources.status == "active", - ) + # 同名查重按「owner + 目录 + 名称」维度:目录从 object_key 解析, + # 不同目录、不同 owner 均允许重名。 + new_directory = resource_directory( + item.object_key, + context.workspace.workspace_id, + context.user.user_id, ) - if existing_active is not None: - raise HTTPException( - status.HTTP_409_CONFLICT, - "a data resource with this name already exists in this workspace", + same_name_rows = ( + await session.execute( + select(DataResources, StorageObjects) + .join( + StorageObjects, + StorageObjects.storage_object_id + == DataResources.storage_object_id, + ) + .where( + DataResources.workspace_id == context.workspace.workspace_id, + DataResources.owner_user_id == context.user.user_id, + DataResources.resource_name == payload.resource_name, + DataResources.status == "active", + DataResources.storage_object_id != item.storage_object_id, + ) ) + ).all() + for existing_resource, existing_object in same_name_rows: + existing_directory = resource_directory( + existing_object.object_key, + existing_resource.workspace_id, + existing_resource.owner_user_id, + ) + if existing_directory == new_directory: + raise HTTPException( + status.HTTP_409_CONFLICT, + "a data resource with this name already exists in this directory", + ) existing = await session.scalar( select(DataResources).where( diff --git a/backend/tests/test_resources.py b/backend/tests/test_resources.py index 750d8aa..ceddf50 100644 --- a/backend/tests/test_resources.py +++ b/backend/tests/test_resources.py @@ -10,81 +10,218 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from backend.resources import compute_jupyter_relative_path, resource_payload +from backend.resources import ( + compute_jupyter_relative_path, + resource_directory, + resource_payload, +) from backend.services.storage import _safe_file_name, _safe_path_segment -@pytest.mark.asyncio -async def test_bind_resource_rejects_duplicate_active_name() -> None: - """Two binds with the same resource_name but different upload_ids raise 409.""" - from backend.resources import bind_resource +_BIND_WS = "01WS0000000000000000000A" +_BIND_USER = "01USR0000000000000000000A" - class _BindSessionMock: - def __init__(self, existing_active=None): - self._existing_active = existing_active - self._calls = 0 - async def scalar(self, _stmt): - self._calls += 1 - # 1st scalar: UploadSessions lookup; 2nd: active name clash check. - if self._calls == 1: - return SimpleNamespace( - upload_id="01UPL0000000000000000000B", - storage_object_id="01OBJ0000000000000000000B", - workspace_id="01WS0000000000000000000A", - user_id="01USR0000000000000000000A", - ) - return self._existing_active +class _ExecuteResult: + def __init__(self, rows): + self._rows = rows - async def get(self, _model, _pk): - return _make_storage_object("01WS0000000000000000000A/01USR0000000000000000000A/data.csv") + def all(self): + return self._rows - def add(self, _obj): - pass - async def flush(self): - pass +class _BindSessionMock: + """Mocked session for bind_resource. - async def refresh(self, _obj, attribute_names=None): - _obj.created_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - _obj.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + ``new_object_key`` is the StorageObjects row of the upload being bound; + ``same_name_rows`` are (DataResources, StorageObjects) candidates already + in the workspace with the same resource_name. + """ - context = SimpleNamespace( + def __init__(self, new_object_key: str, same_name_rows=(), reused_resource=None): + self._new_object_key = new_object_key + self._same_name_rows = list(same_name_rows) + self._reused_resource = reused_resource + self._scalar_calls = 0 + + async def scalar(self, _stmt): + self._scalar_calls += 1 + # 1st scalar: UploadSessions lookup; 2nd: storage_object_id reuse check. + if self._scalar_calls == 1: + return SimpleNamespace( + upload_id="01UPL0000000000000000000B", + storage_object_id="01OBJ0000000000000000000B", + workspace_id=_BIND_WS, + user_id=_BIND_USER, + ) + if self._scalar_calls == 2 and self._reused_resource is not None: + return self._reused_resource + return None + + async def get(self, _model, _pk): + return _make_storage_object(self._new_object_key) + + async def execute(self, _stmt): + # 模拟 SQL 的 owner 过滤与 self-exclusion:只返回属于当前用户且 + # 不是当前 upload 已绑定对象的同名候选。 + rows = [ + (resource, storage_object) + for resource, storage_object in self._same_name_rows + if resource.owner_user_id == _BIND_USER + and resource.storage_object_id != "01OBJ0000000000000000000B" + ] + return _ExecuteResult(rows) + + def add(self, _obj): + pass + + async def flush(self): + pass + + async def refresh(self, _obj, attribute_names=None): + _obj.created_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + _obj.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + + +def _bind_context(): + return SimpleNamespace( request_id="01REQ0000000000000000000A", - user=SimpleNamespace(user_id="01USR0000000000000000000A"), - workspace=SimpleNamespace(workspace_id="01WS0000000000000000000A"), + user=SimpleNamespace(user_id=_BIND_USER), + workspace=SimpleNamespace(workspace_id=_BIND_WS), ) - payload = SimpleNamespace( + + +def _bind_payload(): + return SimpleNamespace( resource_name="data.csv", description=None, visibility="private" ) - request = MagicMock() - # First bind succeeds: no active resource with this name yet. - session1 = _BindSessionMock(existing_active=None) - await bind_resource( - upload_id="01UPL0000000000000000000A", - payload=payload, - request=request, - context=context, - session=session1, - ) - # Second bind with same resource_name fails because an active row exists. - session2 = _BindSessionMock( - existing_active=SimpleNamespace(resource_id="01RES0000000000000000000A") +@pytest.mark.asyncio +async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None: + """Same resource_name in the same directory raises 409.""" + from backend.resources import bind_resource + + existing_rows = [ + ( + _make_resource(_BIND_WS, _BIND_USER), + _make_storage_object(f"{_BIND_WS}/{_BIND_USER}/data.csv"), + ) + ] + session = _BindSessionMock( + new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv", + same_name_rows=existing_rows, ) with pytest.raises(Exception) as exc_info: await bind_resource( upload_id="01UPL0000000000000000000B", - payload=payload, - request=request, - context=context, - session=session2, + payload=_bind_payload(), + request=MagicMock(), + context=_bind_context(), + session=session, ) assert exc_info.value.status_code == 409 assert "already exists" in exc_info.value.detail +@pytest.mark.asyncio +async def test_bind_resource_allows_same_name_in_different_directory() -> None: + """Same resource_name in a different directory binds successfully.""" + from backend.resources import bind_resource + + existing_rows = [ + ( + _make_resource(_BIND_WS, _BIND_USER), + _make_storage_object(f"{_BIND_WS}/{_BIND_USER}/data.csv"), + ) + ] + session = _BindSessionMock( + new_object_key=f"{_BIND_WS}/{_BIND_USER}/subdir/data.csv", + same_name_rows=existing_rows, + ) + result = await bind_resource( + upload_id="01UPL0000000000000000000B", + payload=_bind_payload(), + request=MagicMock(), + context=_bind_context(), + session=session, + ) + assert result["data"]["resource_name"] == "data.csv" + assert result["data"]["jupyter_accessible_path"] == "subdir/data.csv" + + +@pytest.mark.asyncio +async def test_bind_resource_allows_same_name_when_workspace_empty() -> None: + """No same-name rows at all: bind succeeds (root directory).""" + from backend.resources import bind_resource + + session = _BindSessionMock( + new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv", + ) + result = await bind_resource( + upload_id="01UPL0000000000000000000A", + payload=_bind_payload(), + request=MagicMock(), + context=_bind_context(), + session=session, + ) + assert result["data"]["resource_name"] == "data.csv" + + +@pytest.mark.asyncio +async def test_bind_resource_allows_same_name_for_different_owner() -> None: + """其他用户在同目录下的同名资源不阻塞当前用户的绑定。""" + from backend.resources import bind_resource + + other_user = "01USR0000000000000000000B" + existing_rows = [ + ( + _make_resource(_BIND_WS, other_user), + _make_storage_object(f"{_BIND_WS}/{other_user}/data.csv"), + ) + ] + session = _BindSessionMock( + new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv", + same_name_rows=existing_rows, + ) + result = await bind_resource( + upload_id="01UPL0000000000000000000C", + payload=_bind_payload(), + request=MagicMock(), + context=_bind_context(), + session=session, + ) + assert result["data"]["resource_name"] == "data.csv" + + +@pytest.mark.asyncio +async def test_bind_resource_allows_rebinding_same_storage_object() -> None: + """重新绑定同一 upload_id 应走 idempotent 复用路径,不触发 409。""" + from backend.resources import bind_resource + + new_object_key = f"{_BIND_WS}/{_BIND_USER}/data.csv" + existing_resource = _make_resource(_BIND_WS, _BIND_USER) + existing_resource.resource_name = "data.csv" + existing_resource.storage_object_id = "01OBJ0000000000000000000B" + existing_object = _make_storage_object(new_object_key) + existing_object.storage_object_id = "01OBJ0000000000000000000B" + same_name_rows = [(existing_resource, existing_object)] + session = _BindSessionMock( + new_object_key=new_object_key, + same_name_rows=same_name_rows, + reused_resource=existing_resource, + ) + result = await bind_resource( + upload_id="01UPL0000000000000000000B", + payload=_bind_payload(), + request=MagicMock(), + context=_bind_context(), + session=session, + ) + assert result["meta"]["reused"] is True + assert result["data"]["resource_name"] == "data.csv" + + def test_safe_path_segment_cleans_special_characters(): assert _safe_path_segment("train") == "train" assert _safe_path_segment("train v1") == "train_v1" @@ -171,6 +308,26 @@ def test_compute_jupyter_relative_path_for_legacy_and_new_paths(): assert compute_jupyter_relative_path("exp.ipynb", "data.csv") == "data.csv" +def test_resource_directory_parses_object_key(): + ws = "01WS00000000000000000000A" + user = "01USR000000000000000000A" + # 根目录文件:目录为 ""。 + assert resource_directory(f"{ws}/{user}/data.csv", ws, user) == "" + # 子目录 / 多级目录。 + assert resource_directory(f"{ws}/{user}/sub/data.csv", ws, user) == "sub" + assert ( + resource_directory(f"{ws}/{user}/train/v1/data.csv", ws, user) + == "train/v1" + ) + # 旧版 .resources 布局:目录为 ".resources"。 + assert ( + resource_directory(f"{ws}/{user}/.resources/data.csv", ws, user) + == ".resources" + ) + # 不匹配 ws/user 前缀的键按根目录处理。 + assert resource_directory("other-bucket-key.csv", ws, user) == "" + + def test_data_resources_model_allows_duplicate_storage_object_reference() -> None: """With uk_data_resources_object dropped, no unique index covers storage_object_id, so multiple DataResources rows may reference the