Files
Aislo/common_util/common_util_project_delete.py
T
eomsangdonandClaude Opus 5 fbab749ec4 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>
2026-08-08 21:56:17 +09:00

85 lines
3.7 KiB
Python

"""프로젝트 하드 삭제 — 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