feat(B01): 프로젝트 휴지통 — 지움 = 휴지통 · 7 일 뒤 영구 삭제 · 되살리기 (PLAN 592-1 · 요구 454 · 455)
- 대시보드 지움 = 늘 휴지통(deleted_at) · PROJECT_DELETE_HARD_ENABLED 스위치 걷음 → TRASH_RETENTION_DAYS(7) - 영구 삭제(hard_delete_project) = 휴지통 것만 · FOR UPDATE · 이미 지운 것 False 로 건너뜀 · 감사 기록 동작 이름 받음 - 정리 루프에 purge_expired_trash — GET_LOCK 이름 잠금(두 PC 서버 중 하나만) · 기한 지난 것 PROJECT_PURGE - API /api/dashboard/trash/projects — 보기 · 되살리기 = 시스템 관리자(전부) · 회사 관리자(자기 회사) · 영구 삭제 = 시스템 관리자만 - 화면 휴지통 — 시스템 관리 › 휴지통 · 회사 관리 › 휴지통(회사 관리자 · 되살리기만) · 지운 사람 = 감사 기록 - 시험 test_592_1_trash 새로 · test_546 메뉴 기대값에 휴지통 · test_573 복제본 지움을 휴지통 거쳐 영구 삭제로(영구 삭제 = 휴지통 것만) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
"""프로젝트 하드 삭제 — DB 행과 영구저장소 폴더를 실제로 지운다.
|
||||
"""프로젝트 영구 삭제 — DB 행과 영구저장소 폴더를 실제로 지운다.
|
||||
|
||||
평소(배포)에는 쓰이지 않는다. `PROJECT_DELETE_HARD_ENABLED`가 켜진 개발 PC에서만
|
||||
대시보드 삭제 버튼이 이리로 온다. 소프트 삭제는 `B01_Dashboard_Repository`에 그대로 있다.
|
||||
|
||||
`B01_Dashboard_Repository.py`가 이미 700줄 제한을 넘긴 상태라 여기에 따로 둔다.
|
||||
대시보드 지움은 늘 휴지통(`B01_Dashboard_Repository.soft_delete_project`)으로 간다. 여기로
|
||||
오는 길은 둘 — 휴지통 [영구 삭제](시스템 관리자) · `TRASH_RETENTION_DAYS` 지난 휴지통을
|
||||
정리 루프가 비움(`purge_expired_trash`).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -13,12 +12,18 @@ 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
|
||||
from config.config_system import TRASH_RETENTION_DAYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def hard_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool:
|
||||
"""프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다.
|
||||
async def hard_delete_project(
|
||||
project_id: str,
|
||||
actor_id: int,
|
||||
request: Any | None = None,
|
||||
action: str = "PROJECT_HARD_DELETE",
|
||||
) -> bool:
|
||||
"""휴지통의 프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다.
|
||||
|
||||
자식 테이블은 나열하지 않는다 — `projects.id`를 참조하는 테이블이 전부
|
||||
`ON DELETE CASCADE`라 행 하나를 지우면 연쇄로 사라진다(`db_management/001_create_schema.sql`).
|
||||
@@ -31,8 +36,11 @@ async def hard_delete_project(project_id: str, actor_id: int, request: Any | Non
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await connection.begin()
|
||||
# 휴지통에 든 것만 · FOR UPDATE — 두 PC 서버가 같은 행을 같이 지우거나 그 사이 되살리면
|
||||
# 뒤엣것은 기다렸다가 「없음」으로 빠짐.
|
||||
await cursor.execute(
|
||||
"SELECT storage_path FROM projects WHERE id = %s AND deleted_at IS NULL",
|
||||
"""SELECT storage_path FROM projects
|
||||
WHERE id = %s AND deleted_at IS NOT NULL FOR UPDATE""",
|
||||
(project_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
@@ -65,7 +73,7 @@ async def hard_delete_project(project_id: str, actor_id: int, request: Any | Non
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=actor_id,
|
||||
action="PROJECT_HARD_DELETE",
|
||||
action=action,
|
||||
resource_type="project",
|
||||
resource_ref=project_id,
|
||||
request=request,
|
||||
@@ -87,3 +95,55 @@ async def hard_delete_project(project_id: str, actor_id: int, request: Any | Non
|
||||
else:
|
||||
logger.info("하드 삭제 완료: project_id=%s 폴더=%s", project_id, project_root)
|
||||
return True
|
||||
|
||||
|
||||
# 지운 사람 = 마지막 「휴지통으로」 감사 기록 · 없으면 시스템 관리자 한 명(감사 기록 user_id 필수).
|
||||
DELETED_BY_SQL = """COALESCE(
|
||||
(SELECT a.user_id FROM system_audit_logs a
|
||||
WHERE a.action = 'PROJECT_DELETE' AND a.resource_ref = p.id
|
||||
ORDER BY a.id DESC LIMIT 1),
|
||||
(SELECT MIN(su.id) FROM users su WHERE su.role = 'SYSTEM_ADMIN'))"""
|
||||
|
||||
_PURGE_LOCK = "aislo_trash_purge"
|
||||
|
||||
|
||||
async def list_expired_trash(
|
||||
cursor: Any, days: int = TRASH_RETENTION_DAYS
|
||||
) -> list[tuple[str, int]]:
|
||||
"""휴지통에 `days` 일 넘게 있은 프로젝트 → (id, 지운 사람)."""
|
||||
await cursor.execute(
|
||||
f"""SELECT p.id, {DELETED_BY_SQL} FROM projects p
|
||||
WHERE p.deleted_at IS NOT NULL
|
||||
AND p.deleted_at <= CURRENT_TIMESTAMP - INTERVAL %s DAY
|
||||
ORDER BY p.deleted_at""",
|
||||
(days,),
|
||||
)
|
||||
return [(str(row[0]), int(row[1])) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def purge_expired_trash() -> int:
|
||||
"""기한 지난 휴지통을 영구 삭제 · 지운 수.
|
||||
|
||||
두 PC 서버가 DB 하나를 같이 씀 → 이름 잠금(GET_LOCK)을 못 잡으면 이번 차례는 건너뜀 ·
|
||||
그 사이 남이 먼저 지운 행은 `hard_delete_project` 가 False 로 건너뜀.
|
||||
"""
|
||||
removed = 0
|
||||
try:
|
||||
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute("SELECT GET_LOCK(%s, 0)", (_PURGE_LOCK,))
|
||||
if (await cursor.fetchone())[0] != 1:
|
||||
return 0
|
||||
try:
|
||||
for project_id, actor_id in await list_expired_trash(cursor):
|
||||
try:
|
||||
if await hard_delete_project(project_id, actor_id, action="PROJECT_PURGE"):
|
||||
removed += 1
|
||||
except ValueError:
|
||||
pass # 경로가 안전하지 않음 — hard_delete_project 가 로그를 남김 · 다음 것
|
||||
finally:
|
||||
await cursor.execute("SELECT RELEASE_LOCK(%s)", (_PURGE_LOCK,))
|
||||
except Exception:
|
||||
logger.exception("휴지통 정리 실패")
|
||||
if removed:
|
||||
logger.info("휴지통 정리: %d건 영구 삭제 (보관 %d일)", removed, TRASH_RETENTION_DAYS)
|
||||
return removed
|
||||
|
||||
Reference in New Issue
Block a user