diff --git a/B01_Dashboard/B01_Dashboard_Api_Temp.ts b/B01_Dashboard/B01_Dashboard_Api_Temp.ts new file mode 100644 index 00000000..b339c0a7 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Api_Temp.ts @@ -0,0 +1,180 @@ +/* ============================================================================= + * B01_Dashboard_Api_Temp.ts + * 임시 보관함(프로젝트 생성 전 업로드) API 클라이언트 + * + * 백엔드 계약 (B03_FileInput_Router_Temp.py): + * POST /api/temp-uploads 묶음 생성 + * GET /api/temp-uploads 내 보관함 목록 + * DELETE /api/temp-uploads/{batch_id} 묶음 삭제 + * POST /api/temp-uploads/{batch_id}/files 작은 파일 저장 + * POST /api/temp-uploads/{batch_id}/upload-sessions 청크 세션 생성 + * POST /api/temp-uploads/{batch_id}/chunks 청크 저장 + * POST /api/temp-uploads/{batch_id}/finalize 병합·완료 + * GET /api/temp-uploads/{batch_id}/upload-status/{s} 이어올리기 조회 + * POST /api/projects/{id}/temp-uploads/{batch_id}/attach 프로젝트로 이동 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +export interface TempBatchFile { + file_type: string; + original_filename: string; + file_size_bytes: number; + crs_epsg: number | null; +} + +export interface TempBatchPendingSession { + upload_session_id: string; + original_filename: string; + file_size_bytes: number; + total_chunks: number; + completed_chunks: number; + progress_percent: number; +} + +export interface TempBatchItem { + batch_id: string; + name: string; + memo: string | null; + status: string; + files: TempBatchFile[]; + pending_sessions: TempBatchPendingSession[]; + total_size_bytes: number; + required_complete: boolean; + completed_at: string | null; + expires_at: string | null; + linked_project_id: string | null; + created_at: string | null; +} + +export interface TempBatchListResponse { + status: string; + batches: TempBatchItem[]; + retention_days: number; +} + +async function requestJson(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + credentials: "include", + headers: + init.body instanceof FormData + ? init.headers + : { + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.status === "error") { + throw new Error(payload.message ?? `HTTP ${response.status}`); + } + return payload as T; +} + +export async function createTempBatch( + name: string, + memo?: string, +): Promise<{ batch_id: string; name: string }> { + return requestJson("/temp-uploads", { + method: "POST", + body: JSON.stringify({ name, memo: memo || null }), + }); +} + +export async function fetchTempBatches(): Promise { + return requestJson("/temp-uploads", { method: "GET" }); +} + +export async function deleteTempBatch(batchId: string): Promise { + await requestJson(`/temp-uploads/${encodeURIComponent(batchId)}`, { method: "DELETE" }); +} + +/** 작은 파일(csv·prj·tfw·tif)은 한 번에 보낸다. */ +export async function uploadTempBatchFiles( + batchId: string, + files: readonly File[], +): Promise<{ required_complete: boolean }> { + const form = new FormData(); + for (const file of files) form.append("files", file, file.name); + return requestJson(`/temp-uploads/${encodeURIComponent(batchId)}/files`, { + method: "POST", + body: form, + }); +} + +export async function createTempUploadSession( + batchId: string, + file: File, + chunkSizeBytes: number, +): Promise<{ upload_session_id: string; total_chunks: number }> { + return requestJson(`/temp-uploads/${encodeURIComponent(batchId)}/upload-sessions`, { + method: "POST", + body: JSON.stringify({ + original_filename: file.name, + size_bytes: file.size, + chunk_size_bytes: chunkSizeBytes, + }), + }); +} + +export async function uploadTempChunk( + batchId: string, + sessionId: string, + chunkIndex: number, + chunk: Blob, +): Promise<{ completed_chunks: number }> { + const form = new FormData(); + form.append("session_id", sessionId); + form.append("chunk_index", String(chunkIndex)); + form.append("chunk_data", chunk, `chunk_${chunkIndex}`); + return requestJson(`/temp-uploads/${encodeURIComponent(batchId)}/chunks`, { + method: "POST", + body: form, + }); +} + +export async function finalizeTempUpload( + batchId: string, + sessionId: string, + totalChunks: number, +): Promise<{ required_complete: boolean }> { + return requestJson(`/temp-uploads/${encodeURIComponent(batchId)}/finalize`, { + method: "POST", + body: JSON.stringify({ + session_id: sessionId, + total_chunks: totalChunks, + complete_upload: true, + }), + }); +} + +/** 이어올리기 — 이미 서버에 올라간 청크 번호. */ +export async function fetchTempUploadStatus( + batchId: string, + sessionId: string, +): Promise<{ completed_chunk_indexes: number[]; total_chunks: number }> { + return requestJson( + `/temp-uploads/${encodeURIComponent(batchId)}/upload-status/${encodeURIComponent(sessionId)}`, + { method: "GET" }, + ); +} + +export interface TempBatchAttachResponse { + status: string; + project_id: string; + batch_id: string; + moved_files: number; + analysis_started: boolean; +} + +/** 보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다. */ +export async function attachTempBatch( + projectId: string, + batchId: string, +): Promise { + return requestJson( + `/projects/${encodeURIComponent(projectId)}/temp-uploads/${encodeURIComponent(batchId)}/attach`, + { method: "POST" }, + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 5b7c663f..ee275b5b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -41,6 +41,7 @@ import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_M import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile"; import { projectTable } from "./B01_Dashboard_UI_Projects"; import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; +import { buildTempUploadPanel } from "./B01_Dashboard_UI_TempUpload"; import "./B01_Dashboard_UI_Style.css"; export interface DashboardState { @@ -171,7 +172,10 @@ function buildPage(state: DashboardState): HTMLElement { ); } + // 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳이라 프로젝트 목록 + // 바로 다음에 둔다(2026-08-08 사용자 지시). grid.append( + section(L("B01_Temp_Section"), buildTempUploadPanel(), true), section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), section(L("B01_Account_Section_Security"), buildSecurityForm()), ); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css b/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css new file mode 100644 index 00000000..e41b0ac8 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css @@ -0,0 +1,140 @@ +/* ============================================================================= + * B01_Dashboard_UI_Style_Temp.css + * 임시 보관함 섹션 — 등록 폼 + 보관 목록(행에 진행률 표시) + * 색·간격은 theme.css 변수만 사용한다. + * ========================================================================== */ + +.b01-temp__form { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--spacing-16); + align-items: end; +} + +.b01-temp__field { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: 13px; + color: var(--color-muted-text, var(--color-primary-text)); +} + +.b01-temp__field input { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-paper); + color: var(--color-primary-text); + font-size: 13px; +} + +.b01-temp__hint { + margin: var(--spacing-8) 0 var(--spacing-16); + font-size: 12px; + color: var(--color-muted-text, var(--color-primary-text)); +} + +.b01-temp__empty { + padding: var(--spacing-16); + text-align: center; + font-size: 13px; + color: var(--color-muted-text, var(--color-primary-text)); +} + +.b01-temp__list { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b01-temp__row { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-paper); +} + +/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 목록에서 현재 쓸 수 있는 자료와 구분한다. */ +.b01-temp__row.is-linked { + opacity: 0.6; +} + +.b01-temp__row-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.b01-temp__badge { + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + font-size: 11px; + white-space: nowrap; +} + +.b01-temp__row.is-completed .b01-temp__badge { + border-color: var(--color-success); + color: var(--color-success); +} + +.b01-temp__row.is-uploading .b01-temp__badge { + border-color: var(--color-warning); + color: var(--color-warning); +} + +.b01-temp__row-meta { + font-size: 12px; + color: var(--color-muted-text, var(--color-primary-text)); +} + +.b01-temp__files { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.b01-temp__chip { + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + font-size: 11px; +} + +/* 업로드 진행률은 모달이 아니라 이 행 안에서 보여 준다(2026-08-08 사용자 지시). */ +.b01-temp__progress { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + font-size: 11px; +} + +.b01-temp__progress-bar { + height: 6px; + border-radius: 3px; + background: var(--color-canvas); + overflow: hidden; +} + +.b01-temp__progress-bar > div { + height: 100%; + background: var(--color-primary); + transition: width 0.2s ease; +} + +.b01-temp__row-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} + +@media (max-width: 860px) { + .b01-temp__form { + grid-template-columns: 1fr; + } +} diff --git a/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts b/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts new file mode 100644 index 00000000..d277f59e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts @@ -0,0 +1,305 @@ +/* ============================================================================= + * B01_Dashboard_UI_TempUpload.ts + * 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳. + * + * 등록 폼은 프로젝트 등록(B02)과 같은 모양으로 두고, 진행 상황은 별도 모달이 아니라 + * 보관함 **리스트 행**에 표시한다(2026-08-08 사용자 지시). 대용량 라이다는 청크로 + * 나눠 올리고 새로고침 후에도 이어올릴 수 있다. + * ========================================================================== */ + +import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { + createTempBatch, + createTempUploadSession, + deleteTempBatch, + fetchTempBatches, + finalizeTempUpload, + fetchTempUploadStatus, + uploadTempBatchFiles, + uploadTempChunk, + type TempBatchItem, +} from "./B01_Dashboard_Api_Temp"; +import { L } from "./B01_Dashboard_UI_Common"; +import "./B01_Dashboard_UI_Style_Temp.css"; + +/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */ +interface StoredTempSession { + batchId: string; + fileName: string; + fileSize: number; + sessionId: string; + chunkSizeBytes: number; + totalChunks: number; +} + +const CHUNK_UPLOAD_EXT = new Set([".las", ".laz"]); + +function sessionKey(batchId: string, file: File): string { + return `temp:session:${batchId}:${file.name}:${file.size}`; +} + +function formatBytes(bytes: number): string { + const gb = bytes / 1024 / 1024 / 1024; + if (gb >= 1) return `${gb.toFixed(2)} GB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatDate(value: string | null): string { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString(); +} + +function extensionOf(name: string): string { + const index = name.lastIndexOf("."); + return index >= 0 ? name.slice(index).toLowerCase() : ""; +} + +/** + * 보관함 섹션을 만든다. 반환 요소는 대시보드 grid에 그대로 붙인다. + * 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음). + */ +export function buildTempUploadPanel(): HTMLElement { + const panel = document.createElement("div"); + panel.className = "b01-temp"; + + /* ── 등록 폼 (프로젝트 등록과 같은 모양) ───────────────────────────── */ + const form = document.createElement("div"); + form.className = "b01-temp__form"; + + const nameField = document.createElement("label"); + nameField.className = "b01-temp__field"; + const nameCaption = document.createElement("span"); + nameCaption.textContent = L("B01_Temp_Field_Name"); + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.maxLength = 200; + nameInput.placeholder = L("B01_Temp_Field_Name_Placeholder"); + nameField.append(nameCaption, nameInput); + + const fileField = document.createElement("label"); + fileField.className = "b01-temp__field"; + const fileCaption = document.createElement("span"); + fileCaption.textContent = L("B01_Temp_Field_Files"); + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.multiple = true; + fileInput.accept = ".csv,.las,.laz,.tif,.tfw,.prj"; + fileField.append(fileCaption, fileInput); + + const hint = document.createElement("p"); + hint.className = "b01-temp__hint"; + hint.textContent = L("B01_Temp_Hint"); + + const submit = createButton({ + label: L("B01_Temp_Btn_Upload"), + variant: "filled", + onClick: () => void startUpload(), + }); + + form.append(nameField, fileField, submit); + panel.append(form, hint); + + /* ── 보관함 리스트 ─────────────────────────────────────────────────── */ + const listHost = document.createElement("div"); + listHost.className = "b01-temp__list"; + panel.append(listHost); + + let retentionDays = 30; + + function renderRow(batch: TempBatchItem): HTMLElement { + const row = document.createElement("div"); + row.className = `b01-temp__row is-${batch.status}`; + row.dataset.batchId = batch.batch_id; + + const head = document.createElement("div"); + head.className = "b01-temp__row-head"; + const title = document.createElement("strong"); + title.textContent = batch.name; + const badge = document.createElement("span"); + badge.className = "b01-temp__badge"; + badge.textContent = + batch.status === "linked" + ? L("B01_Temp_Status_Linked") + : batch.required_complete + ? L("B01_Temp_Status_Ready") + : L("B01_Temp_Status_Uploading"); + head.append(title, badge); + + const meta = document.createElement("div"); + meta.className = "b01-temp__row-meta"; + const parts = [ + `${batch.files.length}${L("B01_Temp_Meta_FileCount")}`, + formatBytes(batch.total_size_bytes), + ]; + if (batch.status === "linked") { + parts.push(L("B01_Temp_Meta_Linked")); + } else if (batch.expires_at) { + parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`); + } + meta.textContent = parts.join(" · "); + + const files = document.createElement("div"); + files.className = "b01-temp__files"; + for (const file of batch.files) { + const chip = document.createElement("span"); + chip.className = "b01-temp__chip"; + chip.textContent = `${file.file_type.toUpperCase()} · ${file.original_filename}`; + files.append(chip); + } + // 진행 중인 청크 세션은 같은 리스트 행에 진행률 막대로 보여 준다(모달 없음). + for (const pending of batch.pending_sessions) { + const progressRow = document.createElement("div"); + progressRow.className = "b01-temp__progress"; + const label = document.createElement("span"); + label.textContent = `${pending.original_filename} ${pending.progress_percent}%`; + const bar = document.createElement("div"); + bar.className = "b01-temp__progress-bar"; + const fill = document.createElement("div"); + fill.style.width = `${pending.progress_percent}%`; + bar.append(fill); + progressRow.append(label, bar); + files.append(progressRow); + } + + const actions = document.createElement("div"); + actions.className = "b01-temp__row-actions"; + if (batch.status !== "linked") { + actions.append( + createButton({ + label: L("Common_Btn_Delete"), + variant: "danger", + onClick: () => void removeBatch(batch), + }), + ); + } + + row.append(head, meta, files, actions); + return row; + } + + async function refresh(): Promise { + try { + const response = await fetchTempBatches(); + retentionDays = response.retention_days; + hint.textContent = `${L("B01_Temp_Hint")} (${retentionDays}${L("B01_Temp_Hint_Days")})`; + listHost.replaceChildren(); + if (response.batches.length === 0) { + const empty = document.createElement("p"); + empty.className = "b01-temp__empty"; + empty.textContent = L("B01_Temp_Empty"); + listHost.append(empty); + return; + } + for (const batch of response.batches) listHost.append(renderRow(batch)); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error"); + } + } + + async function removeBatch(batch: TempBatchItem): Promise { + if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return; + try { + await deleteTempBatch(batch.batch_id); + showToast(L("B01_Temp_Delete_Success"), "success"); + await refresh(); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B01_Temp_Delete_Failed"), "error"); + } + } + + /** 대용량 파일 1건을 청크로 올린다(이어올리기 포함). 진행률은 리스트 행에 반영. */ + async function uploadLargeFile(batchId: string, file: File): Promise { + const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024; + const key = sessionKey(batchId, file); + let stored: StoredTempSession | null = null; + try { + const raw = localStorage.getItem(key); + stored = raw ? (JSON.parse(raw) as StoredTempSession) : null; + } catch { + stored = null; + } + + let sessionId = stored?.sessionId ?? ""; + let totalChunks = stored?.totalChunks ?? 0; + let done = new Set(); + if (sessionId) { + try { + const status = await fetchTempUploadStatus(batchId, sessionId); + done = new Set(status.completed_chunk_indexes); + totalChunks = status.total_chunks; + } catch { + sessionId = ""; + } + } + if (!sessionId) { + const created = await createTempUploadSession(batchId, file, chunkSizeBytes); + sessionId = created.upload_session_id; + totalChunks = created.total_chunks; + done = new Set(); + } + localStorage.setItem( + key, + JSON.stringify({ + batchId, + fileName: file.name, + fileSize: file.size, + sessionId, + chunkSizeBytes, + totalChunks, + } satisfies StoredTempSession), + ); + + for (let index = 0; index < totalChunks; index += 1) { + if (done.has(index)) continue; + const start = index * chunkSizeBytes; + const end = Math.min(file.size, start + chunkSizeBytes); + await uploadTempChunk(batchId, sessionId, index, file.slice(start, end)); + // 리스트 행 진행률은 서버 정본을 다시 읽어 갱신한다(모달 없이 화면에서 확인). + if (index % 2 === 1 || index === totalChunks - 1) await refresh(); + } + await finalizeTempUpload(batchId, sessionId, totalChunks); + localStorage.removeItem(key); + } + + async function startUpload(): Promise { + const name = nameInput.value.trim(); + const selected = Array.from(fileInput.files ?? []); + if (!name) { + showToast(L("B01_Temp_Error_Name"), "error"); + return; + } + if (selected.length === 0) { + showToast(L("B01_Temp_Error_Files"), "error"); + return; + } + + submit.disabled = true; + try { + const batch = await createTempBatch(name); + const small = selected.filter((file) => !CHUNK_UPLOAD_EXT.has(extensionOf(file.name))); + const large = selected.filter((file) => CHUNK_UPLOAD_EXT.has(extensionOf(file.name))); + + if (small.length > 0) { + await uploadTempBatchFiles(batch.batch_id, small); + await refresh(); + } + for (const file of large) { + await uploadLargeFile(batch.batch_id, file); + } + nameInput.value = ""; + fileInput.value = ""; + showToast(L("B01_Temp_Upload_Success"), "success"); + await refresh(); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B01_Temp_Upload_Failed"), "error"); + await refresh(); + } finally { + submit.disabled = false; + } + } + + void refresh(); + return panel; +} diff --git a/B03_FileInput/B03_FileInput_Repository_Temp.py b/B03_FileInput/B03_FileInput_Repository_Temp.py new file mode 100644 index 00000000..e9ee5c83 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Repository_Temp.py @@ -0,0 +1,327 @@ +"""임시 보관함 Raw SQL 저장소. + +프로젝트 업로드와 같은 청크 세션 테이블(`upload_sessions`)을 쓰되, 프로젝트 대신 +`temp_batch_id`로 묶는다. 묶음·파일 메타는 `temp_upload_batches`/`temp_upload_files`. +""" + +import json +from typing import Any + +import aiomysql + +from config.config_system import TEMP_UPLOAD_RETENTION_DAYS + +# 묶음이 "완료"로 넘어가려면 있어야 하는 파일 종류. B03 필수 슬롯과 같은 기준이다. +REQUIRED_TEMP_FILE_TYPES = frozenset({"csv", "prj", "tfw"}) +POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) + + +def is_batch_required_complete(file_types: set[str]) -> bool: + """필수 파일(csv·prj·tfw + las/laz 1종)이 모두 찼는지.""" + return REQUIRED_TEMP_FILE_TYPES.issubset(file_types) and bool( + file_types & POINT_CLOUD_FILE_TYPES + ) + + +async def create_temp_batch( + connection: aiomysql.Connection, + *, + batch_id: str, + user_id: int, + name: str, + memo: str | None, +) -> None: + """보관함 묶음을 만든다(업로드 시작 상태).""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO temp_upload_batches (id, user_id, name, memo, status) + VALUES (%s, %s, %s, %s, 'uploading') + """, + (batch_id, user_id, name, memo), + ) + + +async def get_temp_batch( + connection: aiomysql.Connection, + *, + batch_id: str, + user_id: int, +) -> dict[str, Any]: + """본인 소유 묶음만 조회한다(남의 보관함 접근 차단).""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, user_id, name, memo, status, completed_at, expires_at, + linked_project_id, created_at + FROM temp_upload_batches + WHERE id = %s AND user_id = %s + """, + (batch_id, user_id), + ) + row = await cursor.fetchone() + if not row: + raise LookupError("임시 보관함 묶음을 찾을 수 없습니다.") + return dict(row) + + +async def list_temp_batches( + connection: aiomysql.Connection, + *, + user_id: int, +) -> list[dict[str, Any]]: + """내 보관함 묶음 목록(최신순). 프로젝트로 옮긴 묶음도 이력으로 함께 준다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, name, memo, status, completed_at, expires_at, + linked_project_id, created_at + FROM temp_upload_batches + WHERE user_id = %s + ORDER BY created_at DESC + """, + (user_id,), + ) + return [dict(row) for row in await cursor.fetchall()] + + +async def list_temp_batch_files( + connection: aiomysql.Connection, + *, + batch_ids: list[str], +) -> dict[str, list[dict[str, Any]]]: + """묶음별 저장 완료 파일을 한 번에 읽는다(목록 화면 N+1 방지).""" + if not batch_ids: + return {} + placeholders = ", ".join(["%s"] * len(batch_ids)) + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f""" + SELECT batch_id, file_type, original_filename, relative_path, + file_size_bytes, crs_epsg, metadata + FROM temp_upload_files + WHERE batch_id IN ({placeholders}) + ORDER BY id + """, + tuple(batch_ids), + ) + rows = [dict(row) for row in await cursor.fetchall()] + grouped: dict[str, list[dict[str, Any]]] = {} + for row in rows: + grouped.setdefault(str(row["batch_id"]), []).append(row) + return grouped + + +async def list_temp_batch_sessions( + connection: aiomysql.Connection, + *, + batch_ids: list[str], +) -> dict[str, list[dict[str, Any]]]: + """묶음별 진행 중 청크 세션(리스트 행 진행률 표시용).""" + if not batch_ids: + return {} + placeholders = ", ".join(["%s"] * len(batch_ids)) + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f""" + SELECT temp_batch_id, id, original_filename, file_size_bytes, + total_chunks, completed_chunks + FROM upload_sessions + WHERE temp_batch_id IN ({placeholders}) AND status = 'in_progress' + ORDER BY updated_at DESC + """, + tuple(batch_ids), + ) + rows = [dict(row) for row in await cursor.fetchall()] + grouped: dict[str, list[dict[str, Any]]] = {} + for row in rows: + grouped.setdefault(str(row["temp_batch_id"]), []).append(row) + return grouped + + +async def upsert_temp_batch_file( + connection: aiomysql.Connection, + *, + batch_id: str, + file_type: str, + original_filename: str, + relative_path: str, + file_size_bytes: int, + crs_epsg: int | None, + metadata: dict[str, Any], +) -> None: + """같은 종류를 다시 올리면 교체한다(슬롯당 1개 규칙).""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO temp_upload_files ( + batch_id, file_type, original_filename, relative_path, + file_size_bytes, crs_epsg, metadata + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + original_filename = VALUES(original_filename), + relative_path = VALUES(relative_path), + file_size_bytes = VALUES(file_size_bytes), + crs_epsg = VALUES(crs_epsg), + metadata = VALUES(metadata) + """, + ( + batch_id, + file_type, + original_filename, + relative_path, + file_size_bytes, + crs_epsg, + json.dumps(metadata, ensure_ascii=False, default=str), + ), + ) + + +async def get_temp_batch_file_types( + connection: aiomysql.Connection, + *, + batch_id: str, +) -> set[str]: + """묶음에 들어 있는 파일 종류 집합.""" + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT file_type FROM temp_upload_files WHERE batch_id = %s", + (batch_id,), + ) + return {str(row[0]).lower() for row in await cursor.fetchall()} + + +async def mark_temp_batch_completed( + connection: aiomysql.Connection, + *, + batch_id: str, +) -> None: + """필수 파일이 다 찼을 때 완료로 올리고 만료일을 찍는다. + + 만료 기준은 **파일이 다 올라온 시점**이다(사용자 지시). 이미 완료된 묶음에 파일을 + 교체해도 처음 완료 시각을 유지해 보관 기간이 무한정 늘어나지 않게 한다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE temp_upload_batches + SET status = 'completed', + completed_at = COALESCE(completed_at, NOW()), + expires_at = COALESCE( + expires_at, DATE_ADD(NOW(), INTERVAL %s DAY) + ) + WHERE id = %s AND status IN ('uploading', 'failed', 'completed') + """, + (TEMP_UPLOAD_RETENTION_DAYS, batch_id), + ) + + +async def mark_temp_batch_linked( + connection: aiomysql.Connection, + *, + batch_id: str, + project_id: str, +) -> None: + """프로젝트로 옮긴 묶음 — 파일은 지우고 이력만 남긴다.""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE temp_upload_batches + SET status = 'linked', linked_project_id = %s, expires_at = NULL + WHERE id = %s + """, + (project_id, batch_id), + ) + await cursor.execute("DELETE FROM temp_upload_files WHERE batch_id = %s", (batch_id,)) + + +async def delete_temp_batch( + connection: aiomysql.Connection, + *, + batch_id: str, + user_id: int, +) -> None: + """묶음 행 삭제(파일·세션은 FK CASCADE로 함께 정리).""" + async with connection.cursor() as cursor: + await cursor.execute( + "DELETE FROM temp_upload_batches WHERE id = %s AND user_id = %s", + (batch_id, user_id), + ) + + +async def create_temp_upload_session( + connection: aiomysql.Connection, + *, + session_id: str, + batch_id: str, + original_filename: str, + file_size_bytes: int, + chunk_size_bytes: int, + total_chunks: int, +) -> None: + """보관함용 청크 세션 생성 — project_id 없이 temp_batch_id로 묶는다.""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + INSERT INTO upload_sessions ( + id, project_id, temp_batch_id, original_filename, file_size_bytes, + chunk_size_bytes, total_chunks, completed_chunks, status, + created_at, updated_at + ) + VALUES (%s, NULL, %s, %s, %s, %s, %s, 0, 'in_progress', NOW(), NOW()) + ON DUPLICATE KEY UPDATE + original_filename = VALUES(original_filename), + file_size_bytes = VALUES(file_size_bytes), + chunk_size_bytes = VALUES(chunk_size_bytes), + total_chunks = VALUES(total_chunks), + status = 'in_progress', + updated_at = NOW() + """, + ( + session_id, + batch_id, + original_filename, + file_size_bytes, + chunk_size_bytes, + total_chunks, + ), + ) + + +async def get_temp_upload_session( + connection: aiomysql.Connection, + *, + batch_id: str, + session_id: str, +) -> dict[str, Any]: + """보관함 청크 세션 조회.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, temp_batch_id, original_filename, file_size_bytes, + chunk_size_bytes, total_chunks, completed_chunks, status + FROM upload_sessions + WHERE id = %s AND temp_batch_id = %s + """, + (session_id, batch_id), + ) + row = await cursor.fetchone() + if not row: + raise LookupError("업로드 세션을 찾을 수 없습니다.") + return dict(row) + + +async def list_expired_temp_batches( + connection: aiomysql.Connection, +) -> list[dict[str, Any]]: + """보관 기한이 지난 묶음(정리 작업용).""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, user_id, name, expires_at + FROM temp_upload_batches + WHERE status <> 'linked' AND expires_at IS NOT NULL AND expires_at <= NOW() + """ + ) + return [dict(row) for row in await cursor.fetchall()] diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py new file mode 100644 index 00000000..1ae2995a --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -0,0 +1,659 @@ +"""임시 보관함 라우터 — 프로젝트 생성 전에 계정에 묶어 자료를 올려 둔다. + +라이다 원본은 업로드에 오래 걸려서 프로젝트 정보가 확정되기 전에 미리 올릴 수 있어야 +한다(2026-08-08 사용자 지시). 저장 구조를 프로젝트 저장소와 똑같이 맞춰 두어 청크 +업로드·병합 엔진을 그대로 재사용하고, 프로젝트로 옮길 때도 같은 상대 경로로 붙인다. +""" + +import asyncio +import logging +import shutil +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Engine import ( + merge_upload_chunks, + remove_chunk_session, + resolve_chunk_session_dir, + resolve_upload_destination, + save_upload_chunk, + save_upload_stream, +) +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata +from B03_FileInput.B03_FileInput_Repository import ( + create_input_file, + get_project_storage_relative_path, + list_completed_chunk_indexes, + mark_upload_session_completed, + mark_upload_session_failed, + upsert_upload_chunk, +) +from B03_FileInput.B03_FileInput_Repository_Temp import ( + create_temp_batch, + create_temp_upload_session, + delete_temp_batch, + get_temp_batch, + get_temp_batch_file_types, + get_temp_upload_session, + is_batch_required_complete, + list_temp_batch_files, + list_temp_batch_sessions, + list_temp_batches, + mark_temp_batch_completed, + mark_temp_batch_linked, + upsert_temp_batch_file, +) +from B03_FileInput.B03_FileInput_Schema import ( + ChunkSessionCreateRequest, + ChunkSessionCreateResponse, + ChunkUploadResponse, + FileUploadDescriptor, + UploadFinalizeRequest, + UploadStatusResponse, +) +from B03_FileInput.B03_FileInput_Schema_Temp import ( + TempBatchAttachResponse, + TempBatchCreateRequest, + TempBatchCreateResponse, + TempBatchFile, + TempBatchItem, + TempBatchListResponse, + TempBatchPendingSession, + TempFileUploadResponse, + TempFileUploadResult, +) +from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email +from common_util.common_util_auth import verify_session +from common_util.common_util_storage import ( + resolve_stored_project_path, + resolve_temp_batch_path, +) +from common_util.common_util_workflow_state import complete_stage +from config.config_db import get_db_pool +from config.config_system import ( + TEMP_UPLOAD_RETENTION_DAYS, + UPLOAD_CHUNK_SIZE_BYTES, + UPLOAD_MAX_FILES, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/temp-uploads", tags=["B03 Temp Upload"]) +attach_router = APIRouter(prefix="/api/projects", tags=["B03 Temp Upload"]) + +_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) + + +def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: + return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) + + +def _iso(value: Any) -> str | None: + return value.isoformat() if value is not None and hasattr(value, "isoformat") else None + + +async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path: + """소유권을 확인하고 묶음 폴더를 돌려준다.""" + await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + return Path(resolve_temp_batch_path(user_id, batch_id)) + + +async def _refresh_batch_status(connection: Any, *, batch_id: str) -> bool: + """필수 파일이 다 찼으면 완료로 올린다. 완료 여부를 돌려준다.""" + file_types = await get_temp_batch_file_types(connection, batch_id=batch_id) + complete = is_batch_required_complete(file_types) + if complete: + await mark_temp_batch_completed(connection, batch_id=batch_id) + return complete + + +@router.post("", response_model=TempBatchCreateResponse) +async def create_batch( + payload: TempBatchCreateRequest, + session: dict[str, Any] = Depends(verify_session), +) -> TempBatchCreateResponse | JSONResponse: + """보관함 묶음(파일 한 세트)을 만든다.""" + batch_id = str(uuid4()) + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await create_temp_batch( + connection, + batch_id=batch_id, + user_id=user_id, + name=payload.name, + memo=payload.memo, + ) + await connection.commit() + resolve_temp_batch_path(user_id, batch_id) + return TempBatchCreateResponse(batch_id=batch_id, name=payload.name) + except Exception: + logger.exception("임시 보관함 묶음 생성 실패: user_id=%s", user_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "보관함 생성 중 오류가 발생했습니다."}, + ) + + +@router.get("", response_model=TempBatchListResponse) +async def list_batches( + session: dict[str, Any] = Depends(verify_session), +) -> TempBatchListResponse | JSONResponse: + """내 보관함 목록 — 파일 목록과 진행 중 세션 진행률을 함께 준다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + batches = await list_temp_batches(connection, user_id=user_id) + ids = [str(batch["id"]) for batch in batches] + files_by_batch = await list_temp_batch_files(connection, batch_ids=ids) + sessions_by_batch = await list_temp_batch_sessions(connection, batch_ids=ids) + + items: list[TempBatchItem] = [] + for batch in batches: + batch_id = str(batch["id"]) + files = files_by_batch.get(batch_id, []) + sessions = sessions_by_batch.get(batch_id, []) + file_types = {str(item["file_type"]).lower() for item in files} + items.append( + TempBatchItem( + batch_id=batch_id, + name=str(batch["name"]), + memo=batch.get("memo"), + status=str(batch["status"]), + files=[ + TempBatchFile( + file_type=str(item["file_type"]), + original_filename=str(item["original_filename"]), + file_size_bytes=int(item["file_size_bytes"]), + crs_epsg=item.get("crs_epsg"), + ) + for item in files + ], + pending_sessions=[ + TempBatchPendingSession( + upload_session_id=str(item["id"]), + original_filename=str(item["original_filename"]), + file_size_bytes=int(item["file_size_bytes"]), + total_chunks=int(item["total_chunks"]), + completed_chunks=int(item["completed_chunks"]), + progress_percent=round( + int(item["completed_chunks"]) + / max(1, int(item["total_chunks"])) + * 100, + 1, + ), + ) + for item in sessions + ], + total_size_bytes=sum(int(item["file_size_bytes"]) for item in files), + required_complete=is_batch_required_complete(file_types), + completed_at=_iso(batch.get("completed_at")), + expires_at=_iso(batch.get("expires_at")), + linked_project_id=( + str(batch["linked_project_id"]) if batch.get("linked_project_id") else None + ), + created_at=_iso(batch.get("created_at")), + ) + ) + return TempBatchListResponse(batches=items, retention_days=TEMP_UPLOAD_RETENTION_DAYS) + except Exception: + logger.exception("임시 보관함 목록 조회 실패: user_id=%s", user_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "보관함 조회 중 오류가 발생했습니다."}, + ) + + +@router.delete("/{batch_id}") +async def remove_batch( + batch_id: str, + session: dict[str, Any] = Depends(verify_session), +) -> JSONResponse: + """보관함 묶음과 저장 파일을 지운다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + await delete_temp_batch(connection, batch_id=batch_id, user_id=user_id) + await connection.commit() + batch_root = Path(resolve_temp_batch_path(user_id, batch_id, create=False)) + shutil.rmtree(batch_root, ignore_errors=True) + return JSONResponse(content={"status": "success", "batch_id": batch_id}) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 삭제 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "보관함 삭제 중 오류가 발생했습니다."}, + ) + + +@router.post("/{batch_id}/files", response_model=TempFileUploadResponse) +async def upload_batch_files( + batch_id: str, + files: list[UploadFile] = File(...), + session: dict[str, Any] = Depends(verify_session), +) -> TempFileUploadResponse | JSONResponse: + """작은 파일(csv·prj·tfw·tif)을 보관함에 바로 저장한다.""" + user_id = int(session["user_id"]) + if not files or len(files) > UPLOAD_MAX_FILES: + message = f"파일은 1~{UPLOAD_MAX_FILES}개까지 가능합니다." + return JSONResponse(status_code=400, content={"status": "error", "message": message}) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) + results: list[TempFileUploadResult] = [] + for upload in files: + descriptor = FileUploadDescriptor( + original_filename=upload.filename or "", + size_bytes=max(1, upload.size or 1), + ) + destination = resolve_upload_destination(batch_root, descriptor) + written_bytes = await save_upload_stream(upload, destination) + metadata = await asyncio.to_thread(analyze_input_metadata, destination) + relative_path = destination.relative_to(batch_root).as_posix() + file_type = destination.suffix.lower().lstrip(".") + crs_epsg = metadata.get("epsg") + await upsert_temp_batch_file( + connection, + batch_id=batch_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + file_size_bytes=written_bytes, + crs_epsg=int(crs_epsg) if crs_epsg is not None else None, + metadata=metadata, + ) + results.append( + TempFileUploadResult( + batch_id=batch_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + size_bytes=written_bytes, + metadata=metadata, + ) + ) + required_complete = await _refresh_batch_status(connection, batch_id=batch_id) + await connection.commit() + return TempFileUploadResponse( + batch_id=batch_id, files=results, required_complete=required_complete + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 파일 저장 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "파일 저장 중 오류가 발생했습니다."}, + ) + finally: + for upload in files: + await upload.close() + + +@router.post("/{batch_id}/upload-sessions", response_model=ChunkSessionCreateResponse) +async def create_batch_upload_session( + batch_id: str, + payload: ChunkSessionCreateRequest, + session: dict[str, Any] = Depends(verify_session), +) -> ChunkSessionCreateResponse | JSONResponse: + """대용량 파일(LAS/LAZ) 청크 세션을 만든다 — 프로젝트 업로드와 같은 규칙.""" + user_id = int(session["user_id"]) + chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) + total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) + session_id = str(uuid4()) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await _batch_root(connection, batch_id=batch_id, user_id=user_id) + await create_temp_upload_session( + connection, + session_id=session_id, + batch_id=batch_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + await connection.commit() + return ChunkSessionCreateResponse( + project_id=batch_id, + upload_session_id=session_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 청크 세션 생성 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, + ) + + +@router.post("/{batch_id}/chunks", response_model=ChunkUploadResponse) +async def upload_batch_chunk( + batch_id: str, + session_id: str = Form(...), + chunk_index: int = Form(...), + chunk_data: UploadFile = File(...), + session: dict[str, Any] = Depends(verify_session), +) -> ChunkUploadResponse | JSONResponse: + """청크 한 조각을 보관함 묶음 폴더에 저장한다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=session_id + ) + if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, + ) + session_dir = resolve_chunk_session_dir(batch_root, session_id) + chunk_path, size_bytes, chunk_hash = await save_upload_chunk( + chunk_data, + session_dir, + chunk_index, + expected_max_bytes=int(upload_session["chunk_size_bytes"]), + ) + completed_chunks = await upsert_upload_chunk( + connection, + session_id=session_id, + chunk_index=chunk_index, + chunk_hash=chunk_hash, + size_bytes=size_bytes, + stored_at=chunk_path.relative_to(batch_root).as_posix(), + ) + return ChunkUploadResponse( + upload_session_id=session_id, + chunk_index=chunk_index, + completed_chunks=completed_chunks, + total_chunks=int(upload_session["total_chunks"]), + chunk_hash=chunk_hash, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 청크 업로드 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, + ) + finally: + await chunk_data.close() + + +@router.get("/{batch_id}/upload-status/{session_id}", response_model=UploadStatusResponse) +async def get_batch_upload_status( + batch_id: str, + session_id: str, + session: dict[str, Any] = Depends(verify_session), +) -> UploadStatusResponse | JSONResponse: + """이어올리기용 — 이미 올라간 청크 번호를 돌려준다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=session_id + ) + completed_indexes = await list_completed_chunk_indexes( + connection, session_id=session_id + ) + return UploadStatusResponse( + upload_session_id=session_id, + upload_status=str(upload_session["status"]), + original_filename=str(upload_session["original_filename"]), + file_size_bytes=int(upload_session["file_size_bytes"]), + chunk_size_bytes=int(upload_session["chunk_size_bytes"]), + total_chunks=int(upload_session["total_chunks"]), + completed_chunks=len(completed_indexes), + completed_chunk_indexes=completed_indexes, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 업로드 상태 조회 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, + ) + + +@router.post("/{batch_id}/finalize", response_model=TempFileUploadResponse) +async def finalize_batch_upload( + batch_id: str, + payload: UploadFinalizeRequest, + session: dict[str, Any] = Depends(verify_session), +) -> TempFileUploadResponse | JSONResponse: + """청크를 병합해 보관함에 저장하고, 필수 파일이 다 차면 완료로 올린다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + final_path: Path | None = None + try: + async with pool.acquire() as connection: + batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=payload.session_id + ) + if int(upload_session["total_chunks"]) != payload.total_chunks: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, + ) + completed_indexes = await list_completed_chunk_indexes( + connection, session_id=payload.session_id + ) + if completed_indexes != list(range(payload.total_chunks)): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, + ) + + descriptor = FileUploadDescriptor( + original_filename=str(upload_session["original_filename"]), + size_bytes=int(upload_session["file_size_bytes"]), + ) + final_path = merge_upload_chunks( + batch_root, descriptor, payload.session_id, payload.total_chunks + ) + metadata = await asyncio.to_thread(analyze_input_metadata, final_path) + relative_path = final_path.relative_to(batch_root).as_posix() + file_type = final_path.suffix.lower().lstrip(".") + crs_epsg = metadata.get("epsg") + + await connection.begin() + try: + await upsert_temp_batch_file( + connection, + batch_id=batch_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + file_size_bytes=int(upload_session["file_size_bytes"]), + crs_epsg=int(crs_epsg) if crs_epsg is not None else None, + metadata=metadata, + ) + await mark_upload_session_completed(connection, session_id=payload.session_id) + required_complete = await _refresh_batch_status(connection, batch_id=batch_id) + await connection.commit() + except Exception: + await connection.rollback() + await mark_upload_session_failed(connection, session_id=payload.session_id) + raise + + remove_chunk_session(batch_root, payload.session_id) + return TempFileUploadResponse( + batch_id=batch_id, + files=[ + TempFileUploadResult( + batch_id=batch_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + size_bytes=int(upload_session["file_size_bytes"]), + metadata=metadata, + ) + ], + required_complete=required_complete, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + if final_path is not None: + final_path.unlink(missing_ok=True) + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + if final_path is not None: + final_path.unlink(missing_ok=True) + logger.exception("임시 보관함 병합 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, + ) + + +@attach_router.post( + "/{project_id}/temp-uploads/{batch_id}/attach", response_model=TempBatchAttachResponse +) +async def attach_temp_batch( + project_id: UUID, + batch_id: str, + session: dict[str, Any] = Depends(verify_session), +) -> TempBatchAttachResponse | JSONResponse: + """보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다. + + 파일 이동 → `input_files` 등록 → stage 0 완료 → WF1·자동 설계 체인까지, B03에서 + 직접 업로드했을 때와 같은 흐름을 탄다. + """ + from B03_FileInput.B03_FileInput_Router import _schedule_background_task + + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + batch = await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + if str(batch["status"]) == "linked": + return JSONResponse( + status_code=400, + content={"status": "error", "message": "이미 프로젝트에 연결된 보관함입니다."}, + ) + files = (await list_temp_batch_files(connection, batch_ids=[batch_id])).get( + batch_id, [] + ) + file_types = {str(item["file_type"]).lower() for item in files} + if not is_batch_required_complete(file_types): + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "필수 파일이 모두 갖춰진 보관함만 연결할 수 있습니다.", + }, + ) + stored_path = await get_project_storage_relative_path(connection, project_id) + + batch_root = Path(resolve_temp_batch_path(user_id, batch_id, create=False)) + project_root = Path(resolve_stored_project_path(stored_path)) + + moved: list[dict[str, Any]] = [] + for item in files: + source = batch_root / str(item["relative_path"]) + if not source.is_file(): + name = item["original_filename"] + raise FileNotFoundError(f"보관함 파일을 찾을 수 없습니다: {name}") + destination = project_root / str(item["relative_path"]) + destination.parent.mkdir(parents=True, exist_ok=True) + await asyncio.to_thread(shutil.move, str(source), str(destination)) + moved.append({**item, "destination": destination}) + + point_cloud_input_id: int | None = None + async with pool.acquire() as connection: + await connection.begin() + try: + for item in moved: + metadata = item.get("metadata") + if isinstance(metadata, str): + import json as _json + + metadata = _json.loads(metadata) + input_file_id = await create_input_file( + connection, + project_id=project_id, + file_type=str(item["file_type"]), + original_filename=str(item["original_filename"]), + relative_path=str(item["relative_path"]), + file_size_bytes=int(item["file_size_bytes"]), + upload_by=user_id, + crs_epsg=item.get("crs_epsg"), + metadata=metadata or {}, + ) + if str(item["file_type"]).lower() in _POINT_CLOUD_FILE_TYPES: + point_cloud_input_id = input_file_id + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 0) + await mark_temp_batch_linked( + connection, batch_id=batch_id, project_id=str(project_id) + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + shutil.rmtree(batch_root, ignore_errors=True) + + analysis_started = point_cloud_input_id is not None + if analysis_started: + _schedule_background_task( + trigger_wf1_analysis_and_email( + project_id=project_id, + input_file_id=point_cloud_input_id, + user_role=str(session["role"]), + ), + task_name=f"b04-preprocess-auto-{project_id}", + ) + logger.info( + "보관함 연결 완료: project_id=%s batch_id=%s 파일=%d건 분석시작=%s", + project_id, + batch_id, + len(moved), + analysis_started, + ) + return TempBatchAttachResponse( + project_id=str(project_id), + batch_id=batch_id, + moved_files=len(moved), + analysis_started=analysis_started, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("보관함 연결 실패: project_id=%s batch_id=%s", project_id, batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "보관함 연결 중 오류가 발생했습니다."}, + ) diff --git a/B03_FileInput/B03_FileInput_Schema_Temp.py b/B03_FileInput/B03_FileInput_Schema_Temp.py new file mode 100644 index 00000000..788a704e --- /dev/null +++ b/B03_FileInput/B03_FileInput_Schema_Temp.py @@ -0,0 +1,97 @@ +"""임시 보관함(프로젝트 생성 전 업로드) 요청·응답 모델.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class TempBatchCreateRequest(BaseModel): + """보관함 묶음 생성 요청 — 프로젝트 등록 폼과 비슷하게 이름만 받는다.""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + name: str = Field(min_length=1, max_length=200) + memo: str | None = Field(default=None, max_length=500) + + +class TempBatchFile(BaseModel): + """묶음 안의 저장 완료 파일 한 건.""" + + file_type: str + original_filename: str + file_size_bytes: int + crs_epsg: int | None = None + + +class TempBatchPendingSession(BaseModel): + """중단된 청크 세션 — 보관함 리스트 행에 진행률로 보여 준다.""" + + upload_session_id: str + original_filename: str + file_size_bytes: int + total_chunks: int + completed_chunks: int + progress_percent: float + + +class TempBatchItem(BaseModel): + """보관함 목록의 묶음 한 건.""" + + batch_id: str + name: str + memo: str | None = None + status: str + files: list[TempBatchFile] + pending_sessions: list[TempBatchPendingSession] + total_size_bytes: int + required_complete: bool + completed_at: str | None = None + expires_at: str | None = None + linked_project_id: str | None = None + created_at: str | None = None + + +class TempBatchListResponse(BaseModel): + """내 보관함 전체 목록.""" + + status: str = "success" + batches: list[TempBatchItem] + retention_days: int + + +class TempBatchCreateResponse(BaseModel): + """묶음 생성 응답.""" + + status: str = "success" + batch_id: str + name: str + + +class TempFileUploadResult(BaseModel): + """보관함에 저장된 파일 한 건.""" + + batch_id: str + file_type: str + original_filename: str + relative_path: str + size_bytes: int + metadata: dict[str, Any] + + +class TempFileUploadResponse(BaseModel): + """보관함 업로드(일반·청크 공통) 응답.""" + + status: str = "success" + batch_id: str + files: list[TempFileUploadResult] + required_complete: bool + + +class TempBatchAttachResponse(BaseModel): + """보관함 묶음을 프로젝트로 옮긴 결과.""" + + status: str = "success" + project_id: str + batch_id: str + moved_files: int + analysis_started: bool diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 1992d9f9..14bec209 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -21,6 +21,8 @@ import { type UploadedFileResult, } from "./B03_FileInput_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; +import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; +import { createTempPicker } from "./B03_FileInput_UI_TempPicker"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -55,6 +57,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 끝난 관리자 전용 점검 화면이라 일반 진행 경로가 아니다(2026-08-08 사용자 지시). const completionRoute = ROUTES.B05_PROFILE; const slots = initializeSlots(); + // 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다. + const tempPicker = createTempPicker(() => updateUploadButton()); const cardMap = new Map(); const resultList = document.createElement("ul"); resultList.className = "b03-file__results"; @@ -130,6 +134,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } function updateUploadButton(): void { + // 보관함 자료를 지정했으면 슬롯 검사와 무관하게 업로드(=이동)를 열어 준다. + if (tempPicker.selected()) { + uploadButton.disabled = false; + pageError.textContent = ""; + return; + } const validation = validateSlots(); uploadButton.disabled = validation !== null; pageError.textContent = validation ?? ""; @@ -577,7 +587,49 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return false; } + /** + * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. + * 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지 + * 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다. + */ + async function attachSelectedTempBatch(): Promise { + const batch = tempPicker.selected(); + if (!batch) return; + activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + if (!activeProjectId) { + pageError.textContent = L("B03_File_Error_Project"); + return; + } + pageError.textContent = ""; + try { + const result = await attachTempBatch(activeProjectId, batch.batch_id); + tempPicker.clear(); + showToast(L("B03_Temp_Attach_Success"), "success"); + await applyUploadOverview(); + if (!result.analysis_started) { + showToast(L("B03_Temp_Attach_NoAnalysis"), "warning"); + return; + } + showToast(L("B03_File_Analysis_InProgress"), "info"); + const analysisComplete = await pollWF1Analysis(activeProjectId); + if (analysisComplete) { + navigateTo(completionRoute); + } else { + showToast(L("B03_File_Analysis_StillRunning"), "warning"); + } + } catch (error) { + const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed"); + pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`; + showToast(L("B03_Temp_Attach_Failed"), "error"); + } + } + async function startChunkedUpload(targetStates = selectedStates()): Promise { + // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. + if (tempPicker.selected()) { + await attachSelectedTempBatch(); + return; + } activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const validation = validateSlots(); if (validation) { @@ -667,6 +719,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { subtitle, overviewBanner, dropzone, + // 대시보드 임시 보관함에서 자료를 끌어오는 자리 — 업로드 컨테이너 안에 둔다. + tempPicker.root, resumeBanner, pageError, uploadButton, diff --git a/B03_FileInput/B03_FileInput_UI_Style_Temp.css b/B03_FileInput/B03_FileInput_UI_Style_Temp.css new file mode 100644 index 00000000..e513674d --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Style_Temp.css @@ -0,0 +1,76 @@ +/* ============================================================================= + * B03_FileInput_UI_Style_Temp.css + * 임시 보관함 불러오기 — 업로드 컨테이너 안 버튼 + 선택 모달 + * 모달 껍데기(.b03-file__modal / -backdrop)는 기존 확인 모달 스타일을 그대로 쓴다. + * ========================================================================== */ + +.b03-file__temp-picker { + display: flex; + align-items: center; + gap: var(--spacing-12); + flex-wrap: wrap; + padding: var(--spacing-12); + border: 1px dashed var(--color-border); + border-radius: var(--radius-cards); +} + +.b03-file__temp-summary { + font-size: 12px; + color: var(--color-text-secondary); +} + +.b03-file__temp-summary.is-active { + color: var(--color-success); + font-weight: 700; +} + +.b03-file__temp-modal { + min-width: min(560px, 92vw); + max-height: 80vh; +} + +.b03-file__temp-list { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + overflow-y: auto; +} + +.b03-file__temp-empty { + padding: var(--spacing-16); + text-align: center; + font-size: 13px; +} + +.b03-file__temp-option { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-paper); + color: var(--color-primary-text); + text-align: left; + cursor: pointer; + font-size: 12px; +} + +.b03-file__temp-option:hover { + border-color: var(--color-primary); +} + +.b03-file__temp-option.is-selected { + border-color: var(--color-primary); + background: var(--color-mist-violet); +} + +.b03-file__temp-option-files { + color: var(--color-text-secondary); +} + +.b03-file__temp-modal-actions { + display: flex; + gap: var(--spacing-8); + justify-content: flex-end; +} diff --git a/B03_FileInput/B03_FileInput_UI_TempPicker.ts b/B03_FileInput/B03_FileInput_UI_TempPicker.ts new file mode 100644 index 00000000..a94754dd --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_TempPicker.ts @@ -0,0 +1,155 @@ +/* ============================================================================= + * B03_FileInput_UI_TempPicker.ts + * 임시 보관함 불러오기 — 대시보드에 미리 올려 둔 자료를 이 프로젝트로 가져온다. + * + * 파일 업로드 컨테이너 안에 버튼을 두고, 누르면 **완료된 보관 묶음만** 목록으로 + * 보여 준다. 선택 후 [확인]을 누르면 지정 상태가 되고, 실제 이동은 화면의 + * [업로드] 버튼을 눌렀을 때 일어난다(2026-08-08 사용자 지시). + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { fetchTempBatches, type TempBatchItem } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; +import "./B03_FileInput_UI_Style_Temp.css"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +function formatBytes(bytes: number): string { + const gb = bytes / 1024 / 1024 / 1024; + if (gb >= 1) return `${gb.toFixed(2)} GB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export interface TempPickerHandle { + /** 업로드 컨테이너에 붙일 요소(버튼 + 선택 안내). */ + root: HTMLElement; + /** 지금 선택된 보관 묶음(없으면 null). */ + selected(): TempBatchItem | null; + /** 연결 완료 후 선택 표시를 지운다. */ + clear(): void; +} + +/** + * 불러오기 버튼과 선택 모달을 만든다. + * `onSelected`는 선택이 바뀔 때마다 호출된다 — 호출측이 업로드 버튼 활성화를 조정한다. + */ +export function createTempPicker( + onSelected: (batch: TempBatchItem | null) => void, +): TempPickerHandle { + const root = document.createElement("div"); + root.className = "b03-file__temp-picker"; + + const openButton = createButton({ + label: L("B03_Temp_Btn_Open"), + variant: "ghost", + onClick: () => void openModal(), + }); + const summary = document.createElement("span"); + summary.className = "b03-file__temp-summary"; + summary.textContent = L("B03_Temp_None"); + root.append(openButton, summary); + + let selectedBatch: TempBatchItem | null = null; + + function applySelection(batch: TempBatchItem | null): void { + selectedBatch = batch; + summary.textContent = batch + ? `${L("B03_Temp_Selected")} ${batch.name} (${batch.files.length}${L("B03_Temp_FileCount")})` + : L("B03_Temp_None"); + summary.classList.toggle("is-active", Boolean(batch)); + onSelected(batch); + } + + async function openModal(): Promise { + let batches: TempBatchItem[] = []; + try { + const response = await fetchTempBatches(); + // 프로젝트에 넣을 수 있는 것은 필수 파일이 다 찬 미연결 묶음뿐이다. + batches = response.batches.filter( + (item) => item.required_complete && item.status !== "linked", + ); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B03_Temp_Load_Failed"), "error"); + return; + } + + const backdrop = document.createElement("div"); + backdrop.className = "b03-file__modal-backdrop"; + const modal = document.createElement("div"); + modal.className = "b03-file__modal b03-file__temp-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + + const title = document.createElement("h3"); + title.textContent = L("B03_Temp_Modal_Title"); + const list = document.createElement("div"); + list.className = "b03-file__temp-list"; + + let pending: TempBatchItem | null = selectedBatch; + + if (batches.length === 0) { + const empty = document.createElement("p"); + empty.className = "b03-file__temp-empty"; + empty.textContent = L("B03_Temp_Modal_Empty"); + list.append(empty); + } + for (const batch of batches) { + const option = document.createElement("button"); + option.type = "button"; + option.className = "b03-file__temp-option"; + option.classList.toggle("is-selected", pending?.batch_id === batch.batch_id); + const name = document.createElement("strong"); + name.textContent = batch.name; + const meta = document.createElement("span"); + meta.textContent = `${batch.files.length}${L("B03_Temp_FileCount")} · ${formatBytes( + batch.total_size_bytes, + )}`; + const files = document.createElement("span"); + files.className = "b03-file__temp-option-files"; + files.textContent = batch.files.map((file) => file.file_type.toUpperCase()).join(", "); + option.append(name, meta, files); + option.addEventListener("click", () => { + pending = batch; + list + .querySelectorAll(".b03-file__temp-option") + .forEach((element) => element.classList.remove("is-selected")); + option.classList.add("is-selected"); + }); + list.append(option); + } + + const actions = document.createElement("div"); + actions.className = "b03-file__temp-modal-actions"; + const close = (): void => backdrop.remove(); + actions.append( + createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: close }), + createButton({ + label: L("Common_Btn_Confirm"), + variant: "filled", + onClick: () => { + if (!pending) { + showToast(L("B03_Temp_Select_Required"), "warning"); + return; + } + applySelection(pending); + close(); + }, + }), + ); + + modal.append(title, list, actions); + backdrop.append(modal); + backdrop.addEventListener("click", (event) => { + if (event.target === backdrop) close(); + }); + document.body.append(backdrop); + } + + return { + root, + selected: () => selectedBatch, + clear: () => applySelection(null), + }; +} diff --git a/common_util/common_util_storage.py b/common_util/common_util_storage.py index 54a6d449..10e8a1ae 100644 --- a/common_util/common_util_storage.py +++ b/common_util/common_util_storage.py @@ -3,7 +3,11 @@ import os from pathlib import PurePosixPath -from config.config_system import PROJECT_STORAGE_STAGE_DIRS, STORAGE_BASE_DIR +from config.config_system import ( + PROJECT_STORAGE_STAGE_DIRS, + STORAGE_BASE_DIR, + TEMP_UPLOAD_DIR_NAME, +) PROJECT_STORAGE_LAYOUT_V2 = ( ("B03_FileInput", "input"), @@ -44,6 +48,32 @@ def ensure_project_storage_layout(project_root: str) -> None: os.makedirs(path, exist_ok=True) +def resolve_temp_batch_path(user_id: int, batch_id: str, *, create: bool = True) -> str: + """임시 보관함 묶음 폴더(`storage/tmp/{user_id}/{batch_id}`)를 돌려준다. + + 내부 구조는 프로젝트 저장소와 똑같이 `B03_FileInput/input/...`을 쓴다 — 그래야 청크 + 저장·병합 엔진(`resolve_upload_destination`, `resolve_chunk_session_dir`)을 그대로 + 재사용할 수 있고, 나중에 프로젝트로 옮길 때도 같은 상대 경로로 이어 붙기만 하면 된다. + """ + if not str(user_id).isdigit(): + raise ValueError("임시 보관함 사용자 식별자가 올바르지 않습니다.") + if not batch_id or any(sep in batch_id for sep in ("/", "\\", "..")): + raise ValueError("임시 보관함 묶음 식별자가 올바르지 않습니다.") + + temp_root = os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME)) + path = os.path.abspath(os.path.join(temp_root, str(user_id), batch_id)) + if os.path.commonpath((temp_root, path)) != temp_root or path == temp_root: + raise ValueError("임시 보관함 경로가 보관함 루트를 벗어났습니다.") + if create: + os.makedirs(path, exist_ok=True) + return path + + +def temp_upload_root() -> str: + """임시 보관함 루트(`storage/tmp`) 절대 경로.""" + return os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME)) + + def resolve_stored_project_path(relative_path: str) -> str: """DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다.""" normalized = PurePosixPath(relative_path.replace("\\", "/")) diff --git a/common_util/common_util_temp_cleanup.py b/common_util/common_util_temp_cleanup.py new file mode 100644 index 00000000..cc81af03 --- /dev/null +++ b/common_util/common_util_temp_cleanup.py @@ -0,0 +1,84 @@ +"""임시 보관함 만료 정리. + +라이다 원본은 수십 GB라 방치하면 디스크를 금방 채운다. 파일이 다 올라온 시각 기준 +`TEMP_UPLOAD_RETENTION_DAYS`가 지난 묶음을 주기적으로 지운다(2026-08-08 사용자 지시). +보관 기간·주기는 `config/config_system.py`에서 조정한다. +""" + +import asyncio +import logging +import os +import shutil + +from B03_FileInput.B03_FileInput_Repository_Temp import ( + delete_temp_batch, + list_expired_temp_batches, +) +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 ( + TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS, + TEMP_UPLOAD_RETENTION_DAYS, +) + +logger = logging.getLogger(__name__) + + +async def cleanup_expired_temp_uploads() -> int: + """만료된 보관함 묶음을 폴더째 지우고 DB 행도 제거한다. 지운 개수를 돌려준다.""" + pool = get_db_pool() + removed = 0 + try: + async with pool.acquire() as connection: + expired = await list_expired_temp_batches(connection) + for batch in expired: + batch_id = str(batch["id"]) + user_id = int(batch["user_id"]) + try: + batch_root = resolve_temp_batch_path(user_id, batch_id, create=False) + shutil.rmtree(batch_root, ignore_errors=True) + except ValueError: + logger.warning("보관함 경로 해석 실패(행만 삭제): batch_id=%s", batch_id) + await delete_temp_batch(connection, batch_id=batch_id, user_id=user_id) + removed += 1 + logger.info( + "임시 보관함 만료 삭제: batch_id=%s name=%s expires_at=%s", + batch_id, + batch.get("name"), + batch.get("expires_at"), + ) + if removed: + await connection.commit() + _remove_empty_user_dirs() + except Exception: + logger.exception("임시 보관함 만료 정리 실패") + return removed + + +def _remove_empty_user_dirs() -> None: + """묶음이 모두 사라진 사용자 폴더는 함께 정리한다.""" + root = temp_upload_root() + if not os.path.isdir(root): + return + for entry in os.listdir(root): + path = os.path.join(root, entry) + if os.path.isdir(path) and not os.listdir(path): + try: + os.rmdir(path) + except OSError: + pass + + +async def cleanup_expired_temp_uploads_loop() -> None: + """서버 시작 직후 한 번, 이후 설정된 주기마다 만료분을 정리한다.""" + interval_seconds = max(1, TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS) * 3600 + logger.info( + "임시 보관함 정리 루프 시작: 보관 %d일, 주기 %d시간", + TEMP_UPLOAD_RETENTION_DAYS, + TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS, + ) + while True: + removed = await cleanup_expired_temp_uploads() + if removed: + logger.info("임시 보관함 정리 완료: %d건 삭제", removed) + await asyncio.sleep(interval_seconds) diff --git a/config/config_system.py b/config/config_system.py index 783355cf..552f9980 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -51,6 +51,13 @@ UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 10 UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp") CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) + +# 프로젝트 생성 전 임시 보관함 — 계정에 묶어 자료를 먼저 올려 두는 공간. +# 라이다 원본이 수십 GB라 보관 기간이 길면 디스크를 금방 채운다. 보관 기간과 정리 주기는 +# 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시). +TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp") +TEMP_UPLOAD_RETENTION_DAYS = int(os.getenv("TEMP_UPLOAD_RETENTION_DAYS", "30")) +TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS = int(os.getenv("TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS", "6")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) SEND_ANALYSIS_COMPLETION_EMAIL = ( os.getenv("SEND_ANALYSIS_COMPLETION_EMAIL", "True").lower() == "true" diff --git a/db_management/010_temp_upload.sql b/db_management/010_temp_upload.sql new file mode 100644 index 00000000..ded05890 --- /dev/null +++ b/db_management/010_temp_upload.sql @@ -0,0 +1,71 @@ +-- 010_temp_upload.sql +-- 프로젝트 생성 전 임시 보관함 (2026-08-08) +-- +-- 라이다 원본은 업로드에 오래 걸린다. 프로젝트 정보가 확정되기 전에 계정에 묶어 +-- 자료를 먼저 올려 두고, 나중에 만든 프로젝트로 옮겨 쓴다. +-- 파일은 storage/tmp/{user_id}/{batch_id}/ 아래에 프로젝트와 같은 구조로 둔다. + +USE aislo_db; + +-- 보관함 묶음 = 프로젝트 하나에 넣을 입력 파일 한 세트. +CREATE TABLE IF NOT EXISTS temp_upload_batches ( + id CHAR(36) PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(200) NOT NULL, + memo VARCHAR(500) NULL, + status ENUM('uploading', 'completed', 'failed', 'linked') NOT NULL DEFAULT 'uploading', + completed_at TIMESTAMP NULL, -- 파일 세트가 다 찬 시각 (만료 기준점) + expires_at TIMESTAMP NULL, -- completed_at + TEMP_UPLOAD_RETENTION_DAYS + linked_project_id CHAR(36) NULL, -- 프로젝트로 옮긴 뒤 이력 보존용 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_temp_batches_user (user_id), + INDEX idx_temp_batches_status (status), + INDEX idx_temp_batches_expires (expires_at), + CONSTRAINT fk_temp_batches_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 묶음 안의 개별 파일. 프로젝트의 input_files와 같은 항목을 담아 두었다가 그대로 옮긴다. +CREATE TABLE IF NOT EXISTS temp_upload_files ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + batch_id CHAR(36) NOT NULL, + file_type VARCHAR(20) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + relative_path VARCHAR(500) NOT NULL, -- 묶음 폴더 기준 상대 경로 + file_size_bytes BIGINT NOT NULL, + crs_epsg INT NULL, + metadata JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_temp_files_batch_type (batch_id, file_type), + INDEX idx_temp_files_batch (batch_id), + CONSTRAINT fk_temp_files_batch + FOREIGN KEY (batch_id) REFERENCES temp_upload_batches(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 청크 업로드 세션을 보관함에서도 그대로 쓰기 위한 완화. +-- project_id는 프로젝트 업로드에서만 채우고, 보관함 업로드는 temp_batch_id를 채운다. +-- FK가 걸린 컬럼은 제약을 먼저 떼야 NULL 허용으로 바꿀 수 있다(MariaDB 1832). +-- 제약 이름이 환경마다 다를 수 있어(수동 생성분은 upload_sessions_ibfk_1) 실제 이름을 +-- 조회해 떼고, 없으면 아무 것도 하지 않는다. +SET @fk_name := ( + SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'upload_sessions' + AND COLUMN_NAME = 'project_id' AND REFERENCED_TABLE_NAME = 'projects' + LIMIT 1 +); +SET @drop_fk := IF( + @fk_name IS NULL, + 'SELECT 1', + CONCAT('ALTER TABLE upload_sessions DROP FOREIGN KEY `', @fk_name, '`') +); +PREPARE drop_fk_stmt FROM @drop_fk; +EXECUTE drop_fk_stmt; +DEALLOCATE PREPARE drop_fk_stmt; + +ALTER TABLE upload_sessions MODIFY COLUMN project_id CHAR(36) NULL; +ALTER TABLE upload_sessions + ADD CONSTRAINT fk_upload_sessions_project_id + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE upload_sessions ADD COLUMN temp_batch_id CHAR(36) NULL AFTER project_id; +ALTER TABLE upload_sessions ADD INDEX idx_upload_sessions_temp_batch (temp_batch_id); diff --git a/main.py b/main.py index abc52ced..f8c55d4d 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,10 @@ from A09_Security.A09_Security_Router import router as a09_security_router from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router +from B03_FileInput.B03_FileInput_Router_Temp import ( + attach_router as b03_temp_attach_router, +) +from B03_FileInput.B03_FileInput_Router_Temp import router as b03_temp_upload_router from B04_PreProcess.B04_PreProcess_Router import router as b04_surface_router from B04_PreProcess.B04_PreProcess_Router_Basins import router as b04_basins_router from B04_PreProcess.B04_PreProcess_Router_Contour import router as b04_surface_contour_router @@ -44,6 +48,7 @@ from B07_Quantity.B07_Quantity_Router import router as b07_quantity_router from B08_DesignDetail.B08_DesignDetail_Router import router as b08_design_router from common_util.common_util_auth import require_company, verify_session from common_util.common_util_resource_monitor import sample_resources_loop +from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool # 설정 import @@ -259,6 +264,7 @@ async def lifespan(app: FastAPI): await connection.commit() cleanup_task = asyncio.create_task(cleanup_expired_sessions()) resource_task = asyncio.create_task(sample_resources_loop()) + temp_cleanup_task = asyncio.create_task(cleanup_expired_temp_uploads_loop()) yield @@ -266,6 +272,7 @@ async def lifespan(app: FastAPI): logger.info("앱 종료 중...") cleanup_task.cancel() resource_task.cancel() + temp_cleanup_task.cancel() stop_frontend_dev() await close_db_pool() logger.info("✓ DB 풀 종료 완료") @@ -349,6 +356,8 @@ app.include_router(b02_proj_register_router) protected = [Depends(verify_session)] protected_with_company = [Depends(verify_session), Depends(require_company)] app.include_router(b03_file_input_router, dependencies=protected_with_company) +app.include_router(b03_temp_upload_router, dependencies=protected) +app.include_router(b03_temp_attach_router, dependencies=protected_with_company) app.include_router(b04_surface_router, dependencies=protected_with_company) app.include_router(b04_surface_contour_router, dependencies=protected_with_company) app.include_router(b04_surface_gis_router, dependencies=protected_with_company) diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 412cc361..ca477e19 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -76,6 +76,36 @@ export const ui_locales_b1 = { B01_Dashboard_Companies: ["회사 관리", "Companies"], B01_Dashboard_Users: ["사용자 관리", "Users"], B01_Dashboard_AuditLogs: ["시스템 로그", "Audit logs"], + /* --- B01 임시 보관함 (프로젝트 생성 전 업로드, 2026-08-08) --- */ + B01_Temp_Section: ["임시 보관함", "Temporary storage"], + B01_Temp_Field_Name: ["보관 이름", "Storage name"], + B01_Temp_Field_Name_Placeholder: ["예: 2026년 3공구 측량자료", "e.g. 2026 Section 3 survey"], + B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"], + B01_Temp_Btn_Upload: ["보관함에 올리기", "Upload to storage"], + B01_Temp_Hint: [ + "프로젝트를 만들기 전에 자료를 먼저 올려 둘 수 있습니다. 나중에 프로젝트 파일 입력 화면에서 불러오면 됩니다.", + "Upload files before creating a project, then pull them in from the project file input page.", + ], + B01_Temp_Hint_Days: ["일 보관", " days retained"], + B01_Temp_Empty: ["보관 중인 자료가 없습니다.", "No stored files yet."], + B01_Temp_Status_Uploading: ["업로드 중", "Uploading"], + B01_Temp_Status_Ready: ["사용 가능", "Ready"], + B01_Temp_Status_Linked: ["프로젝트 연결됨", "Linked"], + B01_Temp_Meta_FileCount: ["개 파일", " files"], + B01_Temp_Meta_Expires: ["보관 만료", "Expires"], + B01_Temp_Meta_Linked: ["프로젝트로 이동 완료", "Moved to project"], + B01_Temp_Error_Name: ["보관 이름을 입력하세요.", "Enter a storage name."], + B01_Temp_Error_Files: ["올릴 파일을 선택하세요.", "Select files to upload."], + B01_Temp_Upload_Success: ["보관함에 저장했습니다.", "Saved to temporary storage."], + B01_Temp_Upload_Failed: ["보관함 업로드에 실패했습니다.", "Failed to upload."], + B01_Temp_Load_Failed: ["보관함을 불러오지 못했습니다.", "Failed to load storage."], + B01_Temp_Delete_Confirm: [ + "이 보관 자료를 삭제할까요? 되돌릴 수 없습니다.", + "Delete this stored set? This cannot be undone.", + ], + B01_Temp_Delete_Success: ["보관 자료를 삭제했습니다.", "Stored set deleted."], + B01_Temp_Delete_Failed: ["삭제에 실패했습니다.", "Failed to delete."], + B01_Dashboard_Profile: ["기본정보", "Profile"], B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"], B01_Dashboard_Table_Project: ["프로젝트명", "Project"], @@ -216,6 +246,28 @@ export const ui_locales_b1 = { B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"], B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"], B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], + /* --- B03 임시 보관함 불러오기 (2026-08-08) --- */ + B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"], + B03_Temp_None: ["선택된 보관 자료 없음", "No stored set selected"], + B03_Temp_Selected: ["선택됨:", "Selected:"], + B03_Temp_FileCount: ["개 파일", " files"], + B03_Temp_Modal_Title: ["보관 자료 선택", "Select stored files"], + B03_Temp_Modal_Empty: [ + "사용할 수 있는 보관 자료가 없습니다. 대시보드 임시 보관함에서 필수 파일을 모두 올려 주세요.", + "No usable stored set. Upload all required files in the dashboard temporary storage first.", + ], + B03_Temp_Select_Required: ["보관 자료를 선택하세요.", "Select a stored set."], + B03_Temp_Load_Failed: ["보관 자료를 불러오지 못했습니다.", "Failed to load stored sets."], + B03_Temp_Attach_Success: [ + "보관 자료를 프로젝트로 옮겼습니다. 분석을 시작합니다.", + "Stored files moved to the project. Analysis started.", + ], + B03_Temp_Attach_Failed: ["보관 자료 연결에 실패했습니다.", "Failed to attach stored files."], + B03_Temp_Attach_NoAnalysis: [ + "파일은 옮겼지만 라이다 파일이 없어 분석을 시작하지 못했습니다.", + "Files moved, but analysis did not start (no point cloud file).", + ], + B03_File_Card_Select: ["파일 선택", "Select file"], B03_File_Card_Remove: ["파일 제거", "Remove file"], B03_File_Error_DuplicateSlot: [