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
@@ -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),
};
}