feat(B01,B03): 프로젝트 생성 전 임시 보관함 (temp upload)

라이다 원본은 업로드에 오래 걸려 프로젝트 정보 확정 전에 미리 올릴 수 있어야 한다.
계정에 묶인 임시 보관함을 만들고, 나중에 만든 프로젝트로 자료를 옮겨 쓴다.

저장·DB
- storage/tmp/{user_id}/{batch_id}/ 아래에 프로젝트 저장소와 동일한 구조를 써서
  청크 저장·병합 엔진(resolve_upload_destination/merge_upload_chunks)을 그대로 재사용
- 010_temp_upload.sql: temp_upload_batches / temp_upload_files 신설,
  upload_sessions.project_id NULL 허용 + temp_batch_id 추가(FK명 조회 후 재생성)
- config: TEMP_UPLOAD_DIR_NAME / TEMP_UPLOAD_RETENTION_DAYS(30) /
  TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS(6)

백엔드
- B03_FileInput_Router_Temp.py: 묶음 생성·목록·삭제, 일반/청크 업로드, finalize,
  이어올리기 상태 조회, 프로젝트 연결(attach)
- attach: 파일 이동 후 input_files 등록, stage 0 완료, WF1·자동 설계 체인 트리거
- common_util_temp_cleanup.py: 완료 시각 기준 만료분 주기 삭제(서버 시작 시 1회 포함)

프론트엔드
- B01 대시보드 임시 보관함 섹션: 프로젝트 등록과 같은 폼 + 보관 목록.
  진행률은 모달이 아니라 리스트 행에 표시, 새로고침 후 이어올리기 지원
- B03 업로드 컨테이너 내부 불러오기 버튼과 선택 모달.
  완료된 묶음만 노출하고, 선택 후 업로드를 누르면 이동과 분석으로 이어짐

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 12:55:14 +09:00
co-authored by Claude Fable 5
parent 336335c611
commit 17834d8189
16 changed files with 2251 additions and 1 deletions
+180
View File
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<TempBatchListResponse> {
return requestJson("/temp-uploads", { method: "GET" });
}
export async function deleteTempBatch(batchId: string): Promise<void> {
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<TempBatchAttachResponse> {
return requestJson(
`/projects/${encodeURIComponent(projectId)}/temp-uploads/${encodeURIComponent(batchId)}/attach`,
{ method: "POST" },
);
}
+4
View File
@@ -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()),
);
@@ -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;
}
}
@@ -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<void> {
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<void> {
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<void> {
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<number>();
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<void> {
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;
}
@@ -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()]
+659
View File
@@ -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": "보관함 연결 중 오류가 발생했습니다."},
)
@@ -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
+54
View File
@@ -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<void> {
// 끝난 관리자 전용 점검 화면이라 일반 진행 경로가 아니다(2026-08-08 사용자 지시).
const completionRoute = ROUTES.B05_PROFILE;
const slots = initializeSlots();
// 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다.
const tempPicker = createTempPicker(() => updateUploadButton());
const cardMap = new Map<FileSlot, HTMLElement>();
const resultList = document.createElement("ul");
resultList.className = "b03-file__results";
@@ -130,6 +134,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
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<void> {
return false;
}
/**
* 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다.
* 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지
* 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다.
*/
async function attachSelectedTempBatch(): Promise<void> {
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<void> {
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
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<void> {
subtitle,
overviewBanner,
dropzone,
// 대시보드 임시 보관함에서 자료를 끌어오는 자리 — 업로드 컨테이너 안에 둔다.
tempPicker.root,
resumeBanner,
pageError,
uploadButton,
@@ -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;
}
@@ -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<void> {
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),
};
}
+31 -1
View File
@@ -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("\\", "/"))
+84
View File
@@ -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)
+7
View File
@@ -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"
+71
View File
@@ -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);
+9
View File
@@ -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)
+52
View File
@@ -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: [