From 139f2c02c1dda171f95c41b85645d5bd026ee6b7 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:19:41 +0800 Subject: [PATCH] fix: delete error --- .env.example | 6 ++--- backend/src/backend/services/storage.py | 11 +++++--- backend/src/backend/storage_api.py | 34 ++++++++++++------------- common/src/common/storage/__init__.py | 2 ++ common/src/common/storage/factory.py | 15 +++++++++++ 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index 9676668..e117caf 100644 --- a/.env.example +++ b/.env.example @@ -61,9 +61,9 @@ S3_PORT=9000 S3_ENDPOINT=http://127.0.0.1:9000 S3_ACCESS_KEY=change-me S3_SECRET_KEY=change-me -S3_WORKSPACE_BUCKET=workspaces -S3_VERSION_BUCKET=versions -S3_RUN_LOG_BUCKET=run-logs +S3_WORKSPACE_BUCKET=workspace +S3_VERSION_BUCKET=version +S3_RUN_LOG_BUCKET=run-log S3_TRASH_BUCKET=trash S3_TRASH_RETENTION_DAYS=30 diff --git a/backend/src/backend/services/storage.py b/backend/src/backend/services/storage.py index 17c02fd..13ddc4f 100644 --- a/backend/src/backend/services/storage.py +++ b/backend/src/backend/services/storage.py @@ -48,7 +48,7 @@ from common.storage.schemas import ( DownloadUrlRequest, ServerObjectRequest, ) -from common.storage import actual_bucket_name, build_storage_uri +from common.storage import USAGE_TYPE_TO_PURPOSE, actual_bucket_name, build_storage_uri # ── shared low-level helpers (module-private) ──────────────────────────── @@ -594,11 +594,13 @@ async def soft_delete_object( } } if item.storage_backend == settings.storage_backend and item.bucket_name and item.object_key: - trash_key = f"{item.bucket_name}/{item.object_key}" + source_purpose = USAGE_TYPE_TO_PURPOSE[item.usage_type] + trash_key = f"{source_purpose}/{item.object_key}" + trash_bucket = actual_bucket_name("trash") try: object_stores = request.app.state.object_stores data = await object_stores[item.bucket_name].get(item.object_key) - await object_stores[actual_bucket_name("trash")].put(trash_key, data) + await object_stores[trash_bucket].put(trash_key, data) await object_stores[item.bucket_name].delete(item.object_key) except Exception as exc: raise HTTPException( @@ -606,6 +608,9 @@ async def soft_delete_object( f"failed to move object to trash: {exc}", ) from exc item.trash_key = trash_key + item.bucket_name = trash_bucket + item.object_key = trash_key + item.storage_uri = build_storage_uri(trash_bucket, trash_key) item.object_status = "deleted" item.deleted_at = _utcnow_naive() item.is_deleted = 1 diff --git a/backend/src/backend/storage_api.py b/backend/src/backend/storage_api.py index 990028b..0ff2a92 100644 --- a/backend/src/backend/storage_api.py +++ b/backend/src/backend/storage_api.py @@ -19,7 +19,7 @@ from common.db.models import ( Workspaces, ) from common.ids import new_ulid -from common.storage import actual_bucket_name, build_storage_uri +from common.storage import USAGE_TYPE_TO_PURPOSE, actual_bucket_name, build_storage_uri from common.storage.schemas import ( CreateUploadRequest, DownloadUrlRequest, @@ -56,20 +56,6 @@ def safe_file_name(value: str) -> str: return name -# Map an upload's usage_type to its purpose (which then resolves to the -# actual bucket / directory via ``actual_bucket_name``). Keeping the -# purpose as the intermediate value means s3 mode and local mode share -# the same routing logic — only the final ``actual_bucket_name`` differs. -USAGE_TYPE_TO_PURPOSE: dict[str, str] = { - "working_copy": "workspace", - "public_script": "workspace", - "data_resource": "workspace", - "snapshot": "workspace", - "version_artifact": "version", - "run_log": "run_log", - "run_result": "run_log", -} - # Pre-resolved bucket map (for read-only callers like services/storage.py). # Re-resolved at module load; re-resolve via resolve_bucket() if the # workspace.artifact_bucket override matters. @@ -479,8 +465,12 @@ async def restore_object( try: # Cross-backend copy: get from trash, put back to source bucket. object_stores = request.app.state.object_stores - data = await object_stores[actual_bucket_name("trash")].get(item.trash_key) - await object_stores[item.bucket_name].put(item.object_key, data) + data = await object_stores[actual_bucket_name("trash")].get(item.object_key) + source_purpose, _, source_key = item.object_key.partition("/") + if not source_purpose: + source_purpose = "workspace" + target_bucket = actual_bucket_name(source_purpose) + await object_stores[target_bucket].put(source_key, data) except Exception as exc: raise HTTPException( status.HTTP_502_BAD_GATEWAY, @@ -488,6 +478,9 @@ async def restore_object( ) from exc item.object_status = "available" item.deleted_at = None + item.bucket_name = target_bucket + item.object_key = source_key + item.storage_uri = build_storage_uri(target_bucket, source_key) # Keep trash_key so the reaper can clean up the duplicate on its # next pass; we don't try to delete it here because a partial # failure would leave the user with no data. @@ -527,10 +520,15 @@ async def purge_trash_object( status.HTTP_409_CONFLICT, "object is not in trash; refuse to hard-delete live data", ) + if item.bucket_name != actual_bucket_name("trash"): + raise HTTPException( + status.HTTP_409_CONFLICT, + "object is not in trash bucket; refuse to hard-delete", + ) if item.trash_key: try: await request.app.state.object_stores[actual_bucket_name("trash")].delete( - item.trash_key + item.object_key ) except Exception as exc: raise HTTPException( diff --git a/common/src/common/storage/__init__.py b/common/src/common/storage/__init__.py index 3a49301..e07db1c 100644 --- a/common/src/common/storage/__init__.py +++ b/common/src/common/storage/__init__.py @@ -27,6 +27,7 @@ from .factory import ( build_storage_config, create_storage, rclone_remote_spec, + USAGE_TYPE_TO_PURPOSE, workspaces_root, ) from .registry import register_backend, registered_backends @@ -36,6 +37,7 @@ __all__ = [ "build_storage_config", "actual_bucket_name", "build_storage_uri", + "USAGE_TYPE_TO_PURPOSE", "workspaces_root", "rclone_remote_spec", "RCLONE_REMOTE_NAME", diff --git a/common/src/common/storage/factory.py b/common/src/common/storage/factory.py index 334340b..4878a54 100644 --- a/common/src/common/storage/factory.py +++ b/common/src/common/storage/factory.py @@ -62,6 +62,21 @@ def create_storage(config: Dict[str, Any]) -> AnyStorageBackend: PURPOSE_BUCKETS: tuple[str, ...] = ("workspace", "version", "run_log", "trash") +# Map an upload's usage_type to its purpose (which then resolves to the +# actual bucket / directory via ``actual_bucket_name``). Keeping the +# purpose as the intermediate value means s3 mode and local mode share +# the same routing logic — only the final ``actual_bucket_name`` differs. +USAGE_TYPE_TO_PURPOSE: dict[str, str] = { + "working_copy": "workspace", + "public_script": "workspace", + "data_resource": "workspace", + "snapshot": "workspace", + "version_artifact": "version", + "run_log": "run_log", + "run_result": "run_log", +} + + def actual_bucket_name(purpose: str) -> str: """把 purpose 名称解析成实际桶路径 / 名(runtime 数据会存在这个字符串里)。