"""프로젝트 하드 삭제 — DB 행과 영구저장소 폴더를 실제로 지운다. 평소(배포)에는 쓰이지 않는다. `PROJECT_DELETE_HARD_ENABLED`가 켜진 개발 PC에서만 대시보드 삭제 버튼이 이리로 온다. 소프트 삭제는 `B01_Dashboard_Repository`에 그대로 있다. `B01_Dashboard_Repository.py`가 이미 700줄 제한을 넘긴 상태라 여기에 따로 둔다. """ import logging import shutil from typing import Any from common_util.common_util_audit import record_audit 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, request: Any | None = None) -> 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 record_audit( cursor, actor_id=actor_id, action="PROJECT_HARD_DELETE", resource_type="project", resource_ref=project_id, request=request, ) 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