feat(B01): 프로젝트 삭제에 개발용 하드 삭제 스위치 추가

대시보드 삭제 버튼은 지금까지 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 21:56:17 +09:00
co-authored by Claude Opus 5
parent d935013ffa
commit fbab749ec4
8 changed files with 153 additions and 4 deletions
+2
View File
@@ -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 {
+7 -1
View File
@@ -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"}
+5 -2
View File
@@ -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);
+1 -1
View File
@@ -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),
}),
);
}