- 기록을 `common_util_audit.record_audit()` 한 곳으로 모음 — 여섯 자리에 흩어져 있던 raw INSERT 제거 - 대상 식별자 칸 신설(`017_audit_log_detail.sql`, 적용 완료) — 기존 `resource_id` 는 INT 라 프로젝트 UUID 를 못 담아 늘 NULL 이었음. 프로젝트 생성·수정·삭제·회사 생성이 대상을 남김 - 접속 주소·브라우저 기록 — 라우터가 요청을 넘겨 주고, 프록시 뒤에서는 X-Forwarded-For 우선 - 보관 기간 `AUDIT_LOG_RETENTION_DAYS` 기본 365일, 임시 보관함 정리 루프에 얹어 함께 정리 - 화면: 시스템 로그 표에 대상·접속 주소 열 추가(대상은 UUID 앞 8자만) 자체검증 — 프로젝트 생성·하드삭제를 실화면에서 돌려 두 줄 모두 `프로젝트 e1040640… · 127.0.0.1 · 2026-09-06 09:54/09:55` 로 남는 것 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
90 lines
3.8 KiB
Python
90 lines
3.8 KiB
Python
"""프로젝트 하드 삭제 — 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
|