From fbab749ec4e2c324bc69add613470af78380da9e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 8 Aug 2026 21:56:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C=EC=97=90=20=EA=B0=9C=EB=B0=9C?= =?UTF-8?q?=EC=9A=A9=20=ED=95=98=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EC=8A=A4?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대시보드 삭제 버튼은 지금까지 projects.deleted_at만 찍는 소프트 삭제였다. 배포에서는 그게 맞다 — 사용자가 올린 라이다 원본은 다른 프로젝트에 재활용할 자산이다. 그러나 개발 중에는 프로젝트를 반복 생성·삭제하는데 정리 잡이 없어 수십 GB 원본이 계속 쌓인다. config_system.py 맨 위에 PROJECT_DELETE_HARD_ENABLED를 두고 갈랐다. 기본값 False라 환경변수를 빠뜨린 배포 환경은 자동으로 안전한 쪽에 선다. True면 projects 행을 실제로 DELETE 하고(자식 테이블은 FK CASCADE로 함께 사라진다) storage/{회사}/{사용자}/{프로젝트ID}/ 폴더를 통째로 지운다. 자식 테이블 목록은 코드에 나열하지 않았다. projects.id 참조가 전부 ON DELETE CASCADE라 행 하나면 충분하고, 목록을 복사해 두면 스키마가 바뀔 때 조용히 어긋난다. 순서는 DB 먼저 커밋, rmtree 나중이다. 파일을 먼저 지우면 DB 실패 시 실체 없는 프로젝트가 목록에 남아 화면이 깨진다. 반대면 rmtree가 실패해도 고아 폴더만 남고 정합성은 유지된다. resolve_stored_project_path()는 끝에서 makedirs를 하므로 삭제에 쓸 수 없다 — 지우기 직전에 폴더를 되살린다. 검증만 하는 resolve_project_root_for_delete()를 따로 뒀고, 저장소 루트 안 · 세그먼트 정확히 4개 · 마지막 세그먼트가 요청 project_id와 일치를 모두 요구한다. DB의 storage_path가 오염돼도 상위 폴더나 남의 폴더를 지우지 못한다. 하드 삭제 모드에서는 확인 모달 문구를 바꿔 원본까지 사라진다고 알린다. Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 2 + B01_Dashboard/B01_Dashboard_Router.py | 8 ++- B01_Dashboard/B01_Dashboard_UI_Modals.ts | 7 +- B01_Dashboard/B01_Dashboard_UI_Projects.ts | 2 +- common_util/common_util_project_delete.py | 84 ++++++++++++++++++++++ common_util/common_util_storage.py | 25 +++++++ config/config_system.py | 24 +++++++ ui_template/ui_template_locale_b1.ts | 5 ++ 8 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 common_util/common_util_project_delete.py diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 2ddb7be8..7e9a782a 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -12,6 +12,8 @@ export interface DashboardUser { role: "SYSTEM_ADMIN" | "ADMIN" | "USER"; is_master: boolean; status: string; + /** 서버가 하드 삭제 모드인지 (`PROJECT_DELETE_HARD_ENABLED`). GET /me에서만 채워진다. */ + project_delete_hard?: boolean; } export interface WorkflowStageState { diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 3a2e1cb8..b67ad343 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -5,6 +5,8 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from common_util.common_util_auth import require_system_admin, verify_session +from common_util.common_util_project_delete import hard_delete_project +from config.config_system import PROJECT_DELETE_HARD_ENABLED from .B01_Dashboard_Repository import ( add_company_member, @@ -88,6 +90,8 @@ async def dashboard_me(session: dict[str, Any] = Depends(verify_session)): user = await get_dashboard_me(int(session["user_id"])) if not user: raise HTTPException(status_code=404, detail="사용자 정보를 찾을 수 없습니다.") + # 삭제 버튼이 원본까지 지우는 모드인지 화면에 알려 경고 문구를 바꾸게 한다. + user["project_delete_hard"] = PROJECT_DELETE_HARD_ENABLED return {"status": "success", "user": user} @@ -232,7 +236,9 @@ async def dashboard_delete_project( if not can_del: raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.") - if not await soft_delete_project(project_id, int(session["user_id"])): + # 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다. + delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project + if not await delete_project(project_id, int(session["user_id"])): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") return {"status": "success"} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 29bfdc37..d0f941a5 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -118,10 +118,13 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem): ); } -export function openDeleteProjectModal(project: ProjectItem): void { +export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void { const warning = document.createElement("p"); warning.className = "b01-dashboard__modal-text"; - warning.textContent = L("B01_Dashboard_Confirm_DeleteProject"); + // 하드 삭제 모드에서는 업로드 원본까지 사라진다 — 문구를 바꿔 실수로 날리는 걸 막는다. + warning.textContent = user.project_delete_hard + ? L("B01_Dashboard_Confirm_DeleteProject_Hard") + : L("B01_Dashboard_Confirm_DeleteProject"); openModal(L("B01_Dashboard_DeleteProject"), [warning], async () => { await deleteProject(project.id); diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts index 871531b7..892938b2 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Projects.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts @@ -36,7 +36,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser createButton({ label: L("Common_Btn_Delete"), variant: "danger", - onClick: () => openDeleteProjectModal(project), + onClick: () => openDeleteProjectModal(currentUser, project), }), ); } diff --git a/common_util/common_util_project_delete.py b/common_util/common_util_project_delete.py new file mode 100644 index 00000000..3008e4a2 --- /dev/null +++ b/common_util/common_util_project_delete.py @@ -0,0 +1,84 @@ +"""프로젝트 하드 삭제 — DB 행과 영구저장소 폴더를 실제로 지운다. + +평소(배포)에는 쓰이지 않는다. `PROJECT_DELETE_HARD_ENABLED`가 켜진 개발 PC에서만 +대시보드 삭제 버튼이 이리로 온다. 소프트 삭제는 `B01_Dashboard_Repository`에 그대로 있다. + +`B01_Dashboard_Repository.py`가 이미 700줄 제한을 넘긴 상태라 여기에 따로 둔다. +""" + +import logging +import shutil + +from common_util.common_util_storage import resolve_project_root_for_delete +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + + +async def hard_delete_project(project_id: str, actor_id: int) -> bool: + """프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다. + + 자식 테이블은 나열하지 않는다 — `projects.id`를 참조하는 테이블이 전부 + `ON DELETE CASCADE`라 행 하나를 지우면 연쇄로 사라진다(`db_management/001_create_schema.sql`). + 코드에 목록을 다시 적으면 스키마가 바뀔 때 조용히 어긋난다. + + 순서는 **DB 먼저, 파일 나중**이다. 파일을 먼저 지우면 rmtree 성공 후 DB가 실패했을 때 + 실체 없는 프로젝트가 목록에 남아 화면이 깨진다. 반대로 DB가 먼저 확정되면 rmtree가 + 실패해도 고아 폴더(디스크 낭비)만 남고 시스템 자체는 정합하다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await cursor.execute( + "SELECT storage_path FROM projects WHERE id = %s AND deleted_at IS NULL", + (project_id,), + ) + row = await cursor.fetchone() + if not row: + await connection.rollback() + return False + stored_path = row[0] + + # 경로 검증을 커밋 전에 끝낸다 — 폴더를 못 지울 경로면 DB도 건드리지 않는다. + project_root: str | None = None + if stored_path: + try: + project_root = resolve_project_root_for_delete(str(stored_path), project_id) + except ValueError: + await connection.rollback() + logger.error( + "하드 삭제 중단 — 저장 경로가 안전하지 않다: project_id=%s storage_path=%r", + project_id, + stored_path, + ) + raise + else: + logger.warning("하드 삭제 — storage_path가 비어 DB만 지운다: project_id=%s", project_id) + + await cursor.execute("DELETE FROM projects WHERE id = %s", (project_id,)) + if cursor.rowcount <= 0: + await connection.rollback() + return False + # 감사 기록은 프로젝트가 사라진 뒤에도 남는다. resource_id는 FK가 없어 고아가 되지 않는다. + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'PROJECT_HARD_DELETE', 'project', NULL)""", + (actor_id,), + ) + await connection.commit() + + if project_root: + try: + shutil.rmtree(project_root) + except FileNotFoundError: + pass + except OSError as error: + # DB는 이미 확정됐다. 여기서 실패해도 삭제 자체는 성공이다 — 경로를 남겨 수동 정리한다. + logger.error( + "하드 삭제 — DB는 지웠으나 폴더가 남았다. 수동 정리 필요: %s (%s)", + project_root, + error, + ) + else: + logger.info("하드 삭제 완료: project_id=%s 폴더=%s", project_id, project_root) + return True diff --git a/common_util/common_util_storage.py b/common_util/common_util_storage.py index 10e8a1ae..bfeb4ee2 100644 --- a/common_util/common_util_storage.py +++ b/common_util/common_util_storage.py @@ -74,6 +74,31 @@ def temp_upload_root() -> str: return os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME)) +def resolve_project_root_for_delete(relative_path: str, project_id: str) -> str: + """삭제용 프로젝트 루트를 해석한다 — 폴더를 만들지 않는다. + + `resolve_stored_project_path()`는 끝에서 `makedirs`와 레이아웃 생성을 한다. 지우기 + 직전에 그걸 쓰면 폴더를 되살려 놓고 지우는 꼴이 된다. 그래서 검증만 하는 짝을 둔다. + + DB의 `storage_path`가 오염돼도 남의 폴더를 지우지 못하게 네 겹으로 막는다: + 저장소 루트 안, 세그먼트 정확히 4개, 마지막 세그먼트가 요청받은 프로젝트 ID와 일치. + """ + normalized = PurePosixPath(relative_path.replace("\\", "/")) + if normalized.is_absolute() or ".." in normalized.parts: + raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.") + # storage/{회사}/{사용자}/{프로젝트ID} — 상위 폴더를 통째로 지우는 사고를 막는다. + if len(normalized.parts) != 4 or normalized.parts[0] != "storage": + raise ValueError("삭제 대상 경로는 storage/회사/사용자/프로젝트ID 형태여야 합니다.") + if normalized.parts[3] != str(project_id): + raise ValueError("저장 경로의 프로젝트 ID가 삭제 요청과 일치하지 않습니다.") + + storage_root = os.path.abspath(STORAGE_BASE_DIR) + path = os.path.abspath(os.path.join(storage_root, *normalized.parts[1:])) + if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root: + raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.") + return path + + def resolve_stored_project_path(relative_path: str) -> str: """DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다.""" normalized = PurePosixPath(relative_path.replace("\\", "/")) diff --git a/config/config_system.py b/config/config_system.py index c47f7b14..ef49142a 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -13,6 +13,30 @@ from dotenv import load_dotenv PROJECT_ROOT = Path(__file__).resolve().parent.parent load_dotenv(PROJECT_ROOT / ".env") os.environ["PYTHONUTF8"] = "1" + +# ═════════════════════════════════════════════════════════════════════════ +# ⚠ 0. 위험 스위치 — 프로젝트 하드 삭제 +# ═════════════════════════════════════════════════════════════════════════ +# 대시보드의 프로젝트 삭제 버튼이 무엇을 지우는지 결정한다. 눈에 띄라고 맨 위에 둔다. +# +# False (배포 기본값) +# 지금까지의 동작. `projects.deleted_at`만 찍어 목록에서 감춘다. 사용자가 올린 +# 라이다 원본은 나중에 다른 프로젝트 개발에 재활용할 수 있는 자산이라 남긴다. +# +# True (개발 PC 전용) +# `projects` 행을 실제로 DELETE 하고(자식 테이블은 FK CASCADE로 함께 사라진다), +# 영구저장소의 `storage/{회사}/{사용자}/{프로젝트ID}/` 폴더를 통째로 지운다. +# B03 업로드 원본까지 없어진다. 되돌릴 수 없다. +# +# 개발 중에는 프로젝트를 반복 생성·삭제하는데 소프트 삭제만 하면 수십 GB 라이다 원본이 +# 디스크에 계속 쌓인다. 정리 잡도 없다. 그래서 개발 PC의 `.env`에서만 True로 켠다 +# (2026-08-08 사용자 지시). +# +# .env → PROJECT_DELETE_HARD_ENABLED=True +# +# 기본값이 False라 환경변수를 빠뜨린 배포 환경은 자동으로 안전한 쪽에 선다. +PROJECT_DELETE_HARD_ENABLED = os.getenv("PROJECT_DELETE_HARD_ENABLED", "False").lower() == "true" + # ───────────────────────────────────────────────────────────────────────── # 1. FastAPI 서버 # ───────────────────────────────────────────────────────────────────────── diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 67f5ea74..3f2db8f6 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -171,6 +171,11 @@ export const ui_locales_b1 = { "프로젝트를 삭제하시겠습니까? 되돌릴 수 없습니다.", "Delete this project? This cannot be undone.", ], + // 하드 삭제 모드(PROJECT_DELETE_HARD_ENABLED=True) 전용 경고 + B01_Dashboard_Confirm_DeleteProject_Hard: [ + "[하드 삭제 모드] 업로드한 라이다 원본과 모든 계산 결과가 서버에서 영구 삭제됩니다. 복구할 수 없습니다. 삭제하시겠습니까?", + "[Hard delete mode] The uploaded LiDAR source and every computed result will be permanently erased from the server. This cannot be recovered. Delete anyway?", + ], B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"], B01_Dashboard_Confirm_LastAdmin: [ "회사의 유일한 관리자는 삭제할 수 없습니다.",