From ac481a0764ef4efd99e4d16077dd53ac6d111554 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 08:31:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=9C=B4=EC=A7=80=ED=86=B5=20=E2=80=94=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=3D=20=ED=9C=B4=EC=A7=80=ED=86=B5=20=C2=B7=207=20?= =?UTF-8?q?=EC=9D=BC=20=EB=92=A4=20=EC=98=81=EA=B5=AC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=C2=B7=20=EB=90=98=EC=82=B4=EB=A6=AC=EA=B8=B0=20(PLAN=20592-?= =?UTF-8?q?1=20=C2=B7=20=EC=9A=94=EA=B5=AC=20454=20=C2=B7=20455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대시보드 지움 = 늘 휴지통(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 --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 31 ++++- .../B01_Dashboard_Repository_Trash.py | 74 +++++++++++ B01_Dashboard/B01_Dashboard_Router.py | 12 +- B01_Dashboard/B01_Dashboard_Router_Trash.py | 51 ++++++++ B01_Dashboard/B01_Dashboard_UI_Modals.ts | 7 +- .../B01_Dashboard_UI_View_SysLogs.ts | 4 +- B01_Dashboard/B01_Dashboard_UI_View_Trash.ts | 111 ++++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Views.ts | 4 + common_util/common_util_project_delete.py | 78 +++++++++-- common_util/common_util_temp_cleanup.py | 4 +- config/config_system.py | 24 +--- resources/tester/test_546_b01_shell.py | 7 +- resources/tester/test_573_1_clone_de.py | 3 + resources/tester/test_592_1_trash.py | 123 ++++++++++++++++++ ui_template/ui_template_locale_b1.ts | 9 +- 15 files changed, 488 insertions(+), 54 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_Repository_Trash.py create mode 100644 B01_Dashboard/B01_Dashboard_Router_Trash.py create mode 100644 B01_Dashboard/B01_Dashboard_UI_View_Trash.ts create mode 100644 resources/tester/test_592_1_trash.py diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 74d771a12..77c6c8e03 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -12,8 +12,6 @@ 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 { @@ -464,6 +462,35 @@ export function deleteProject(projectId: string): Promise { return request(`/dashboard/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" }); } +/** 휴지통(592) 한 줄 — days_left = 영구 삭제까지 남은 날 */ +export interface TrashProject { + id: string; + name: string; + project_kind?: string | null; + company_id?: number | null; + company_name?: string | null; + deleted_at: string; + deleted_by_name?: string | null; + deleted_by_email?: string | null; + days_left: number; +} + +export async function fetchTrashProjects(): Promise { + return (await request<{ projects: TrashProject[] }>("/dashboard/trash/projects")).projects; +} + +export function restoreTrashProject(projectId: string): Promise { + return request(`/dashboard/trash/projects/${encodeURIComponent(projectId)}/restore`, { + method: "POST", + }); +} + +export function purgeTrashProject(projectId: string): Promise { + return request(`/dashboard/trash/projects/${encodeURIComponent(projectId)}`, { + method: "DELETE", + }); +} + export function updateDashboardUser( userId: number, payload: AdminUpdateUserRequest, diff --git a/B01_Dashboard/B01_Dashboard_Repository_Trash.py b/B01_Dashboard/B01_Dashboard_Repository_Trash.py new file mode 100644 index 000000000..c7acb1eaf --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Trash.py @@ -0,0 +1,74 @@ +"""B01_Dashboard 프로젝트 휴지통(592) — 목록 · 되살리기 · 영구 삭제는 common_util_project_delete.""" + +from typing import Any + +import aiomysql + +from common_util.common_util_audit import record_audit +from common_util.common_util_project_delete import DELETED_BY_SQL +from config.config_db import get_db_pool +from config.config_system import TRASH_RETENTION_DAYS + + +def days_left(seconds_left: int | None) -> int: + """영구 삭제까지 남은 날 — 하루 덜 찼어도 하루로(올림) · 지났으면 0.""" + if seconds_left is None or seconds_left <= 0: + return 0 + return -(-int(seconds_left) // 86400) + + +async def list_trash_projects(company_id: int | None = None) -> list[dict[str, Any]]: + """휴지통 — 최근 지운 것부터 · company_id 면 그 회사 것만.""" + where = " AND p.company_id = %s" if company_id is not None else "" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f"""SELECT t.*, du.name AS deleted_by_name, du.email AS deleted_by_email + FROM (SELECT p.id, p.name, p.project_kind, p.company_id, c.name AS company_name, + p.deleted_at, {DELETED_BY_SQL} AS deleted_by, + TIMESTAMPDIFF(SECOND, CURRENT_TIMESTAMP, + p.deleted_at + INTERVAL %s DAY) AS seconds_left + FROM projects p LEFT JOIN companies c ON c.id = p.company_id + WHERE p.deleted_at IS NOT NULL{where}) t + LEFT JOIN users du ON du.id = t.deleted_by + ORDER BY t.deleted_at DESC""", + (TRASH_RETENTION_DAYS, company_id) + if company_id is not None + else (TRASH_RETENTION_DAYS,), + ) + rows = list(await cursor.fetchall()) + for row in rows: + row["days_left"] = days_left(row.pop("seconds_left")) + return rows + + +async def get_trash_project(project_id: str) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT id, company_id, name FROM projects WHERE id = %s AND deleted_at IS NOT NULL", + (project_id,), + ) + return await cursor.fetchone() + + +async def restore_project(project_id: str, actor_id: int, request: Any | None = None) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await cursor.execute( + "UPDATE projects SET deleted_at = NULL WHERE id = %s AND deleted_at IS NOT NULL", + (project_id,), + ) + changed = cursor.rowcount > 0 + if changed: + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_RESTORE", + resource_type="project", + resource_ref=project_id, + request=request, + ) + await connection.commit() + return changed diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 654e249ec..44b5a68d7 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -11,9 +11,8 @@ from common_util import common_util_calc_sites as calc_sites from common_util.common_util_audit import record_audit from common_util.common_util_auth import require_company, require_system_admin, verify_session from common_util.common_util_email import send_email -from common_util.common_util_project_delete import hard_delete_project from config.config_db import get_db_pool -from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED +from config.config_system import APP_PUBLIC_BASE_URL from .B01_Dashboard_Knowledge_Chat import router as knowledge_chat_router from .B01_Dashboard_Map import fetch_base_tile, geocode_address, search_address @@ -58,6 +57,7 @@ from .B01_Dashboard_Repository_Members import ( set_project_members, ) from .B01_Dashboard_Router_Assets import router as assets_router +from .B01_Dashboard_Router_Trash import router as trash_router from .B01_Dashboard_Schema import ( AddMemberRequest, AdminUpdateUserRequest, @@ -106,8 +106,6 @@ 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} @@ -418,9 +416,8 @@ async def dashboard_delete_project( if not can_del: raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.") - # 개발 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"]), request): + # 지움 = 늘 휴지통(592) · 영구 삭제는 휴지통 [영구 삭제] · 기한 지나면 정리 루프. + if not await soft_delete_project(project_id, int(session["user_id"]), request): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") return {"status": "success"} @@ -616,4 +613,5 @@ async def write_calc_sites( router.include_router(assets_router) +router.include_router(trash_router) router.include_router(knowledge_chat_router) diff --git a/B01_Dashboard/B01_Dashboard_Router_Trash.py b/B01_Dashboard/B01_Dashboard_Router_Trash.py new file mode 100644 index 000000000..364a414d3 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Router_Trash.py @@ -0,0 +1,51 @@ +"""B01_Dashboard 프로젝트 휴지통 API(592) — 본체 라우터가 include 함. + +보기 · 되살리기 = 시스템 관리자(전부) · 회사 관리자(자기 회사) · 영구 삭제 = 시스템 관리자만. +""" + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request + +from common_util.common_util_auth import require_system_admin +from common_util.common_util_project_delete import hard_delete_project + +from .B01_Dashboard_Repository_Trash import get_trash_project, list_trash_projects, restore_project +from .B01_Dashboard_Scope import _require_company_id, _same_company, require_company_admin + +router = APIRouter() + +_GONE = "휴지통에 없는 프로젝트입니다." + + +@router.get("/trash/projects") +async def trash_projects(session: dict[str, Any] = Depends(require_company_admin)): + company_id = None if session["role"] == "SYSTEM_ADMIN" else _require_company_id(session) + return {"status": "success", "projects": await list_trash_projects(company_id)} + + +@router.post("/trash/projects/{project_id}/restore") +async def trash_restore( + project_id: str, + request: Request, + session: dict[str, Any] = Depends(require_company_admin), +): + project = await get_trash_project(project_id) + if not project: + raise HTTPException(status_code=404, detail=_GONE) + if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, project.get("company_id")): + raise HTTPException(status_code=403, detail="다른 회사의 프로젝트는 되살릴 수 없습니다.") + if not await restore_project(project_id, int(session["user_id"]), request): + raise HTTPException(status_code=404, detail=_GONE) + return {"status": "success"} + + +@router.delete("/trash/projects/{project_id}") +async def trash_purge( + project_id: str, + request: Request, + session: dict[str, Any] = Depends(require_system_admin), +): + if not await hard_delete_project(project_id, int(session["user_id"]), request): + raise HTTPException(status_code=404, detail=_GONE) + return {"status": "success"} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 7ec8e65cf..7f9a647dc 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -303,13 +303,10 @@ export async function openEditProjectModal( ); } -export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void { +export function openDeleteProjectModal(_user: DashboardUser, project: ProjectItem): void { const warning = document.createElement("p"); warning.className = "b01-dashboard__modal-text"; - // 하드 삭제 모드에서는 업로드 원본까지 사라진다 — 문구를 바꿔 실수로 날리는 걸 막는다. - warning.textContent = user.project_delete_hard - ? L("B01_Dashboard_Confirm_DeleteProject_Hard") - : L("B01_Dashboard_Confirm_DeleteProject"); + warning.textContent = L("B01_Dashboard_Confirm_DeleteProject"); openModal(L("B01_Dashboard_DeleteProject"), [warning], async () => { await deleteProject(project.id); diff --git a/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts b/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts index 4e16dcf98..16d7d9940 100644 --- a/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts +++ b/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts @@ -16,8 +16,10 @@ const PAGE = 100; export const ACTION_KO: Record = { PROJECT_CREATE: "프로젝트 생성", PROJECT_UPDATE: "프로젝트 수정", - PROJECT_DELETE: "프로젝트 삭제", + PROJECT_DELETE: "프로젝트 휴지통으로", + PROJECT_RESTORE: "프로젝트 되살리기", PROJECT_HARD_DELETE: "프로젝트 영구 삭제", + PROJECT_PURGE: "휴지통 기한 지나 영구 삭제", COMPANY_CREATE: "회사 생성", CALC_SITE_CHANGE: "계산 자리 변경", RATE_ANOMALY: "호출 이상", diff --git a/B01_Dashboard/B01_Dashboard_UI_View_Trash.ts b/B01_Dashboard/B01_Dashboard_UI_View_Trash.ts new file mode 100644 index 000000000..988d3a896 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_Trash.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * B01_Dashboard_UI_View_Trash.ts + * 휴지통(592) — 지운 프로젝트 표(이름 · 종류 · 회사 · 지운 사람 · 지운 시각 · 남은 날) + [되살리기] · [영구 삭제]. + * 시스템 관리자 = 전부 · 영구 삭제까지 · 회사 관리자 = 자기 회사 것 되살리기만(서버가 거름). + * 남은 날이 다 되면 서버 정리 루프가 DB 행 · 폴더째 지움. + * ========================================================================== */ + +import { askConfirm } from "@ui/ui_template_modal"; +import { showToast } from "@ui/ui_template_toast"; +import { createButton } from "@ui/ui_template_elements"; +import { + fetchTrashProjects, + purgeTrashProject, + restoreTrashProject, + type TrashProject, +} from "./B01_Dashboard_Api_Fetch"; +import { formatDate, formatTime } from "./B01_Dashboard_UI_Common"; +import { badge, dataTable, emptyState, nameCell, pageFrame } from "./B01_Dashboard_UI_PageKit"; +import { kindBadge } from "./B01_Dashboard_UI_ProjectList_Parts"; +import { el as h, loadPage } from "./B01_Dashboard_UI_View_Parts"; +import type { ViewCtx } from "./B01_Dashboard_UI_Views"; + +const COLUMNS = [ + { label: "이름" }, + { label: "종류" }, + { label: "회사" }, + { label: "지운 사람" }, + { label: "지운 시각" }, + { label: "남은 날" }, + { label: "" }, +]; + +export function mountTrash(host: HTMLElement, ctx: ViewCtx): void { + const admin = ctx.role === "SYSTEM_ADMIN"; + const run = (): void => + loadPage(host, async (stage) => { + const rows = await fetchTrashProjects(); + const frame = pageFrame( + stage, + "휴지통", + "지운 프로젝트는 7 일 동안 여기 있다가 자료째 영구 삭제됩니다. 그 전에 되살릴 수 있습니다.", + ); + const act = async ( + p: TrashProject, + ask: string, + call: (id: string) => Promise, + done: string, + danger = false, + ): Promise => { + if (!(await askConfirm({ title: p.name, message: ask, danger }))) return; + try { + await call(p.id); + showToast(done, "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : "처리하지 못했습니다.", "error"); + } + run(); + }; + const actions = (p: TrashProject): HTMLElement => { + const box = h("div", "b01-sys-tool"); + box.append( + createButton({ + label: "되살리기", + variant: "ghost", + size: 28, + onClick: () => + void act( + p, + "프로젝트를 되살려 목록에 다시 보이게 합니다.", + restoreTrashProject, + "되살렸습니다.", + ), + }), + ); + if (admin) + box.append( + createButton({ + label: "영구 삭제", + variant: "danger-ghost", + size: 28, + onClick: () => + void act( + p, + "DB 자료와 프로젝트 폴더를 지금 영구 삭제합니다. 되돌릴 수 없습니다.", + purgeTrashProject, + "영구 삭제했습니다.", + true, + ), + }), + ); + return box; + }; + frame.body.append( + rows.length + ? dataTable( + COLUMNS, + rows.map((p) => [ + p.name, + kindBadge(p.project_kind), + p.company_name ?? "-", + nameCell(p.deleted_by_name ?? "-", p.deleted_by_email), + `${formatDate(p.deleted_at)} ${formatTime(p.deleted_at)}`.trim(), + badge(`${p.days_left} 일`, p.days_left <= 1 ? "danger" : "neutral"), + actions(p), + ]), + ) + : emptyState({ icon: "trash", text: "휴지통이 비어 있습니다." }), + ); + }); + run(); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Views.ts b/B01_Dashboard/B01_Dashboard_UI_Views.ts index 2569388ce..ac2ea86f2 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Views.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Views.ts @@ -25,6 +25,7 @@ import { mountSysDefaults } from "./B01_Dashboard_UI_View_SysDefaults"; import { mountSysLogs } from "./B01_Dashboard_UI_View_SysLogs"; import { mountSysOverview } from "./B01_Dashboard_UI_View_SysOverview"; import { mountSysUsers } from "./B01_Dashboard_UI_View_SysUsers"; +import { mountTrash } from "./B01_Dashboard_UI_View_Trash"; import { mountUiKit } from "@ui/ui_template_ui_kit"; /** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */ @@ -65,12 +66,14 @@ export const VIEWS: View[] = [ { key: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: mountPeople }, { key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: mountCompany }, { key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: mountCompanyDefaults }, + { key: "company-trash", title: "휴지통", group: "company", roles: ["ADMIN"], icon: "trash", mount: mountTrash }, // 시스템 관리 { key: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: mountSysOverview }, { key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: mountSysCompanies }, { key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: mountSysUsers }, { key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: mountSysDefaults }, { key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: mountSysLogs }, + { key: "sys-trash", title: "휴지통", group: "system", roles: ["SYSTEM_ADMIN"], icon: "trash", mount: mountTrash }, { key: "ui-kit", title: "부품 모음", group: "system", roles: ["SYSTEM_ADMIN"], icon: "adjustments", mount: mountUiKit }, // 도움 { key: "guide", title: "사용자 가이드", group: "help", roles: ALL, icon: "book", mount: mountGuide }, @@ -94,6 +97,7 @@ export const VIEW_HELP: Record = { "sys-users": "09_시스템_관리", "sys-defaults": "09_시스템_관리", "sys-logs": "09_시스템_관리", + "sys-trash": "09_시스템_관리", chat: "11_지식_챗봇", me: "12_내_정보", }; diff --git a/common_util/common_util_project_delete.py b/common_util/common_util_project_delete.py index 7aa318bba..ea5e70c7b 100644 --- a/common_util/common_util_project_delete.py +++ b/common_util/common_util_project_delete.py @@ -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 diff --git a/common_util/common_util_temp_cleanup.py b/common_util/common_util_temp_cleanup.py index d864c547b..b91179f85 100644 --- a/common_util/common_util_temp_cleanup.py +++ b/common_util/common_util_temp_cleanup.py @@ -15,6 +15,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import ( list_expired_temp_batches, ) from common_util.common_util_audit import purge_expired_audit_logs +from common_util.common_util_project_delete import purge_expired_trash from common_util.common_util_storage import resolve_temp_batch_path, temp_upload_root from config.config_db import get_db_pool from config.config_system import ( @@ -82,6 +83,7 @@ async def cleanup_expired_temp_uploads_loop() -> None: removed = await cleanup_expired_temp_uploads() if removed: logger.info("임시 보관함 정리 완료: %d건 삭제", removed) - # 시스템 로그 보관 기간 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다. + # 시스템 로그 보관 기간 · 휴지통 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다. await purge_expired_audit_logs() + await purge_expired_trash() await asyncio.sleep(interval_seconds) diff --git a/config/config_system.py b/config/config_system.py index 4c49991d3..cc58bd2fd 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -15,27 +15,11 @@ load_dotenv(PROJECT_ROOT / ".env") os.environ["PYTHONUTF8"] = "1" # ═════════════════════════════════════════════════════════════════════════ -# ⚠ 0. 위험 스위치 — 프로젝트 하드 삭제 +# 0. 프로젝트 휴지통 # ═════════════════════════════════════════════════════════════════════════ -# 대시보드의 프로젝트 삭제 버튼이 무엇을 지우는지 결정한다. 눈에 띄라고 맨 위에 둔다. -# -# False (배포 기본값) -# 지금까지의 동작. `projects.deleted_at`만 찍어 목록에서 감춘다. 사용자가 올린 -# 라이다 원본은 나중에 다른 프로젝트 개발에 재활용할 수 있는 자산이라 남긴다. -# -# True (개발 PC 전용) -# `projects` 행을 실제로 DELETE 하고(자식 테이블은 FK CASCADE로 함께 사라진다), -# 영구저장소의 `storage/{회사}/{사용자}/{프로젝트ID}/` 폴더를 통째로 지운다. -# B03 업로드 원본까지 없어진다. 되돌릴 수 없다. -# -# 개발 중에는 프로젝트를 반복 생성·삭제하는데 소프트 삭제만 하면 수십 GB 라이다 원본이 -# 디스크에 계속 쌓인다. 정리 잡도 없다. 그래서 개발 PC의 `.env`에서만 True로 켠다 -# (2026-08-08 사용자 지시). -# -# .env → PROJECT_DELETE_HARD_ENABLED=True -# -# 기본값이 False라 환경변수를 빠뜨린 배포 환경은 자동으로 안전한 쪽에 선다. -PROJECT_DELETE_HARD_ENABLED = os.getenv("PROJECT_DELETE_HARD_ENABLED", "False").lower() == "true" +# 대시보드 지움 = 휴지통(`projects.deleted_at`) · 이 날수가 지나면 정리 루프가 DB 행과 +# `storage/{회사}/{사용자}/{프로젝트ID}/` 폴더를 영구 삭제(요구 455). +TRASH_RETENTION_DAYS = int(os.getenv("TRASH_RETENTION_DAYS", "7")) # ───────────────────────────────────────────────────────────────────────── # 1. FastAPI 서버 diff --git a/resources/tester/test_546_b01_shell.py b/resources/tester/test_546_b01_shell.py index b1f2d3512..7854cadee 100644 --- a/resources/tester/test_546_b01_shell.py +++ b/resources/tester/test_546_b01_shell.py @@ -58,7 +58,10 @@ def test_역할별_메뉴(): help_ = ["help:guide", "help:chat"] user, admin, forest, system = out assert user == [*work, "company:company", "company:company-defaults", *help_] - assert admin == [*work, "company:people", "company:company", "company:company-defaults", *help_] + assert admin == [ + *work, "company:people", "company:company", "company:company-defaults", + "company:company-trash", *help_, + ] # fmt: skip # 산림청 = 받은 예정노선 없음(보내는 쪽) assert forest == [ "work:home", "work:projects", "work:temp", @@ -67,7 +70,7 @@ def test_역할별_메뉴(): assert system == [ *work, "company:people", "company:company", "system:sys-overview", "system:sys-companies", "system:sys-users", - "system:sys-defaults", "system:sys-logs", "system:ui-kit", *help_, + "system:sys-defaults", "system:sys-logs", "system:sys-trash", "system:ui-kit", *help_, ] # fmt: skip diff --git a/resources/tester/test_573_1_clone_de.py b/resources/tester/test_573_1_clone_de.py index 4659db613..ef9b692a0 100644 --- a/resources/tester/test_573_1_clone_de.py +++ b/resources/tester/test_573_1_clone_de.py @@ -109,11 +109,14 @@ def _copytree(source: Path, target: Path) -> None: async def _hard_delete(project_id, actor): + from B01_Dashboard.B01_Dashboard_Repository import soft_delete_project from common_util.common_util_project_delete import hard_delete_project from config.config_db import close_db_pool, init_db_pool await init_db_pool() try: + # 영구 삭제는 휴지통 것만(592) — 먼저 휴지통으로 + await soft_delete_project(project_id, actor) return await hard_delete_project(project_id, actor) finally: await close_db_pool() diff --git a/resources/tester/test_592_1_trash.py b/resources/tester/test_592_1_trash.py new file mode 100644 index 000000000..388255ca9 --- /dev/null +++ b/resources/tester/test_592_1_trash.py @@ -0,0 +1,123 @@ +"""592-1 프로젝트 휴지통 지킴 시험. + +- 남은 날 = 올림 · 지났으면 0 +- 7 일 판정 — 8 일 전 지운 것만 기한 지남 · 6 일 전 · 살아 있는 것은 아님 +- 영구 삭제 = 휴지통 것만 · 이미 지운 것은 건너뜀(False) · DB 행 · 폴더 0 · 감사 기록 +- 되살리기 = deleted_at 비움 +- 정리 잠금 — 남이 잠금을 쥐면 이번 차례 건너뜀 +시험 행은 DB 에 바로 넣고(이름 「sub3임시」 · 폴더는 임시 storage) 끝에 행 · 감사 기록째 지움. +""" + +import asyncio +import uuid + +import pytest + +from B01_Dashboard.B01_Dashboard_Repository_Trash import days_left + + +def test_days_left_rounds_up(): + assert [days_left(v) for v in (None, -5, 0, 1, 86400, 86401, 7 * 86400)] == [ + 0, + 0, + 0, + 1, + 1, + 2, + 7, + ] + + +async def _scenario(storage_root): + from B01_Dashboard.B01_Dashboard_Repository_Trash import restore_project + from common_util.common_util_project_delete import ( + _PURGE_LOCK, + hard_delete_project, + list_expired_trash, + purge_expired_trash, + ) + from config.config_db import close_db_pool, get_db_pool, init_db_pool + + try: + await init_db_pool() + except Exception as error: # DB 없는 PC + pytest.skip(f"DB 연결 안 됨: {error}") + pool = get_db_pool() + ids = {key: str(uuid.uuid4()) for key in ("old", "young", "live")} + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT id, company_id FROM users WHERE role = 'SYSTEM_ADMIN' LIMIT 1") + actor, company = await cursor.fetchone() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + for key, ago in (("old", 8), ("young", 6), ("live", None)): + pid = ids[key] + (storage_root / "c" / "u" / pid).mkdir(parents=True) + (storage_root / "c" / "u" / pid / "x.txt").write_text("x") + await cursor.execute( + """INSERT INTO projects (id, user_id, company_id, name, storage_path, deleted_at) + VALUES (%s, %s, %s, %s, %s, + IF(%s IS NULL, NULL, CURRENT_TIMESTAMP - INTERVAL %s DAY))""", + ( + pid, + actor, + company or 1, + f"sub3임시_592_{key}", + f"storage/c/u/{pid}", + ago, + ago, + ), + ) + await connection.commit() + expired = {pid for pid, _ in await list_expired_trash(cursor)} + assert ids["old"] in expired + assert ids["young"] not in expired and ids["live"] not in expired + + # 남이 잠금을 쥔 동안은 정리 차례를 건너뜀 — 행 그대로 + async with pool.acquire() as holder, holder.cursor() as cursor: + await cursor.execute("SELECT GET_LOCK(%s, 0)", (_PURGE_LOCK,)) + assert (await cursor.fetchone())[0] == 1 + try: + assert await purge_expired_trash() == 0 + finally: + await cursor.execute("SELECT RELEASE_LOCK(%s)", (_PURGE_LOCK,)) + + # 살아 있는 것은 영구 삭제 안 됨 + assert await hard_delete_project(ids["live"], actor) is False + assert (storage_root / "c" / "u" / ids["live"]).is_dir() + # 기한 지난 것 영구 삭제 → 행 · 폴더 0 · 다시 부르면 건너뜀 + assert await hard_delete_project(ids["old"], actor, action="PROJECT_PURGE") is True + assert await hard_delete_project(ids["old"], actor, action="PROJECT_PURGE") is False + assert not (storage_root / "c" / "u" / ids["old"]).exists() + # 되살리기 + assert await restore_project(ids["young"], actor) is True + assert await restore_project(ids["young"], actor) is False + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "SELECT id, deleted_at FROM projects WHERE id IN (%s, %s, %s)", tuple(ids.values()) + ) + rows = dict(await cursor.fetchall()) + await cursor.execute( + "SELECT action FROM system_audit_logs WHERE resource_ref = %s", (ids["old"],) + ) + actions = [row[0] for row in await cursor.fetchall()] + assert ids["old"] not in rows + assert rows[ids["young"]] is None + assert actions == ["PROJECT_PURGE"] + finally: + async with pool.acquire() as connection, connection.cursor() as cursor: + marks = ", ".join(["%s"] * len(ids)) + await cursor.execute(f"DELETE FROM projects WHERE id IN ({marks})", tuple(ids.values())) + await cursor.execute( + f"DELETE FROM system_audit_logs WHERE resource_ref IN ({marks})", + tuple(ids.values()), + ) + await connection.commit() + await close_db_pool() + + +def test_trash_purge_restore(tmp_path, monkeypatch): + import common_util.common_util_storage as storage + + root = tmp_path / "storage" + monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(root)) + asyncio.run(_scenario(root)) diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index e87f4d8d6..41ef12823 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -178,13 +178,8 @@ export const ui_locales_b1 = { // 확인 메시지 B01_Dashboard_Confirm_DeleteProject: [ - "프로젝트를 삭제하시겠습니까? 되돌릴 수 없습니다.", - "Delete this project? This cannot be undone.", - ], - // 하드 삭제 모드(PROJECT_DELETE_HARD_ENABLED=True) 전용 경고 - B01_Dashboard_Confirm_DeleteProject_Hard: [ - "[하드 삭제 모드] 업로드한 라이다 원본과 모든 계산 결과가 서버에서 영구 삭제됩니다. 복구할 수 없습니다. 삭제하시겠습니까?", - "[Hard delete mode] The uploaded LiDAR source and every computed result will be permanently erased from the server. This cannot be recovered. Delete anyway?", + "프로젝트를 휴지통으로 옮깁니다. 7 일 안에는 휴지통에서 되살릴 수 있고, 7 일이 지나면 자료째 영구 삭제됩니다.", + "Move this project to the trash? It can be restored from the Trash within 7 days; after that it is permanently deleted with all its data.", ], B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"], B01_Dashboard_Confirm_LastAdmin: [