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:
@@ -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" },
|
||||
);
|
||||
}
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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("\\", "/"))
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user