refactor(B03): 파일 입력 라우터 700줄 초과 분리 — 보조·청크 업로드

872줄 한 파일을 셋으로 나눔 (동작 불변, 순수 분리).
- `B03_FileInput_Router.py` 309줄 — 파일 업로드·현황·워크플로 엔드포인트
- `B03_FileInput_Router_Chunks.py` 390줄 — 세션 생성·조각 전송·마무리·진행 조회
  (자체 APIRouter 를 본체가 `include_router` 로 붙여 경로 문자열 불변)
- `B03_FileInput_Router_Helpers.py` 268줄 — 필수 파일 판정·중복 지문 판별·단계 기록·알림

검증: 라우트 7개 경로·메서드 동일(`GET upload-overview|upload-status|workflow-state`,
`POST files|upload-sessions|chunks|finalize`), 공용 브라우저에서 노선 5종 실제 업로드 성공
(shp metadata preview_path 포함), ruff check 통과, tmp/tests 378 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:15:19 +09:00
co-authored by Claude Opus 5
parent 3b89ba4fac
commit c4aac6f299
3 changed files with 667 additions and 577 deletions
@@ -0,0 +1,389 @@
"""B03 청크 업로드 엔드포인트 — 세션 생성·조각 전송·마무리·진행 조회.
라우터 본체(`B03_FileInput_Router.py`)가 700줄을 넘어 떼어낸 조각이다(2026-09-04).
경로·태그는 본체와 같다 — 본체가 `include_router` 로 붙여 URL 이 그대로 유지된다.
"""
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
import aiomysql
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,
save_upload_chunk,
)
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
from B03_FileInput.B03_FileInput_Repository import (
create_input_file,
create_upload_session,
get_project_storage_relative_path,
get_upload_session,
list_completed_chunk_indexes,
mark_upload_session_completed,
mark_upload_session_failed,
supersede_previous_input_files,
upsert_upload_chunk,
)
from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response
from B03_FileInput.B03_FileInput_Router_Helpers import (
_ANALYSIS_RUNNING_MESSAGE,
_already_uploaded,
_complete_file_input_if_ready,
_schedule_background_task,
_total_chunks,
_write_stage_metadata,
)
from B03_FileInput.B03_FileInput_Schema import (
ChunkSessionCreateRequest,
ChunkSessionCreateResponse,
ChunkUploadResponse,
FileUploadDescriptor,
FileUploadResponse,
UploadedFileResult,
UploadFinalizeRequest,
UploadStatusResponse,
)
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
from common_util.common_util_workflow_state import (
is_analysis_running,
)
from config.config_db import get_db_pool
from config.config_system import (
UPLOAD_CHUNK_SIZE_BYTES,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["B03 File Input"])
@router.post("/{project_id}/upload-sessions", response_model=ChunkSessionCreateResponse)
async def create_project_upload_session(
project_id: UUID,
payload: ChunkSessionCreateRequest,
session: dict[str, Any] = Depends(verify_session),
) -> ChunkSessionCreateResponse | JSONResponse:
"""대용량 파일 청크 업로드 세션을 생성한다."""
# LAS 없는 설계를 켠 상태면 포인트클라우드는 받지 않는다 — 큰 LAS는 이 경로로
# 들어오므로 여기서 막지 않으면 `/files` 검사를 통째로 비켜 간다.
if payload.las_free and Path(payload.original_filename).suffix.lower() in {".las", ".laz"}:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.",
},
)
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())
point_cloud_input_id: int | None = None
skipped: ChunkSessionCreateResponse | None = None
pool = get_db_pool()
try:
async with pool.acquire() as connection:
await get_project_storage_relative_path(connection, project_id)
# 분석이 도는 중이면 새 자료를 받지 않는다 — 받아 봐야 분석 2개가 같은 산출물
# 경로에서 부딪힌다. 화면도 버튼을 잠그지만 새로고침으로 우회할 수 있어 여기서 막는다.
async with connection.cursor(aiomysql.DictCursor) as cursor:
if await is_analysis_running(cursor, str(project_id)):
return JSONResponse(
status_code=409,
content={"status": "error", "message": _ANALYSIS_RUNNING_MESSAGE},
)
skipped = await _already_uploaded(connection, project_id, payload)
if skipped is not None:
if payload.complete_upload:
await connection.begin()
try:
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
payload.las_free,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
else:
await create_upload_session(
connection,
session_id=session_id,
project_id=project_id,
original_filename=payload.original_filename,
file_size_bytes=payload.size_bytes,
chunk_size_bytes=chunk_size_bytes,
total_chunks=total_chunks,
)
if skipped is not None:
if point_cloud_input_id is not None:
_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}",
)
return skipped
return ChunkSessionCreateResponse(
project_id=str(project_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 lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B03 청크 세션 생성 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."},
)
@router.post("/{project_id}/chunks", response_model=ChunkUploadResponse)
async def upload_project_chunk(
project_id: UUID,
session_id: str = Form(...),
chunk_index: int = Form(...),
chunk_data: UploadFile = File(...),
) -> ChunkUploadResponse | JSONResponse:
"""단일 파일 청크를 B03 임시 폴더에 저장하고 DB에 기록한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
upload_session = await get_upload_session(
connection,
project_id=project_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": "청크 인덱스가 범위를 벗어났습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
session_dir = resolve_chunk_session_dir(project_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"]),
)
relative_chunk_path = chunk_path.relative_to(project_root).as_posix()
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=relative_chunk_path,
)
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 lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception(
"B03 청크 업로드 실패: project_id=%s session_id=%s",
project_id,
session_id,
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."},
)
finally:
await chunk_data.close()
@router.post("/{project_id}/finalize", response_model=FileUploadResponse)
async def finalize_project_upload(
project_id: UUID,
payload: UploadFinalizeRequest,
session: dict[str, Any] = Depends(verify_session),
) -> FileUploadResponse | JSONResponse:
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
pool = get_db_pool()
final_path: Path | None = None
point_cloud_input_id: int | None = None
try:
async with pool.acquire() as connection:
upload_session = await get_upload_session(
connection,
project_id=project_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,
)
expected_indexes = list(range(payload.total_chunks))
if completed_indexes != expected_indexes:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
descriptor = FileUploadDescriptor(
original_filename=str(upload_session["original_filename"]),
size_bytes=int(upload_session["file_size_bytes"]),
)
final_path = merge_upload_chunks(
project_root,
descriptor,
payload.session_id,
payload.total_chunks,
)
metadata = await asyncio.to_thread(analyze_input_metadata, final_path)
# 다음에 같은 파일이 올라오면 전송을 건너뛸 수 있도록 지문을 함께 남긴다.
fingerprint = payload.fingerprint or None
if fingerprint:
metadata = {**metadata, "fingerprint": fingerprint}
relative_path = final_path.relative_to(project_root).as_posix()
file_type = final_path.suffix.lower().lstrip(".")
crs_epsg = metadata.get("epsg")
await connection.begin()
try:
input_file_id = await create_input_file(
connection,
project_id=project_id,
file_type=file_type,
original_filename=descriptor.original_filename,
relative_path=relative_path,
file_size_bytes=descriptor.size_bytes,
upload_by=None,
crs_epsg=int(crs_epsg) if crs_epsg is not None else None,
metadata=metadata,
)
# 같은 이름의 옛 행은 내려 둔다 — 목록·분석이 최신 1건만 보게 한다.
await supersede_previous_input_files(
connection,
project_id,
descriptor.original_filename,
input_file_id,
)
await mark_upload_session_completed(connection, session_id=payload.session_id)
if payload.complete_upload:
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
payload.las_free,
)
await connection.commit()
except Exception:
await connection.rollback()
await mark_upload_session_failed(connection, session_id=payload.session_id)
raise
remove_chunk_session(project_root, payload.session_id)
result = UploadedFileResult(
input_file_id=input_file_id,
original_filename=descriptor.original_filename,
file_type=file_type,
relative_path=relative_path,
size_bytes=descriptor.size_bytes,
metadata=metadata,
)
stage_root = project_root / "B03_FileInput"
_write_stage_metadata(stage_root, project_id, [result])
# 업로드 직후 안내 메일은 보내지 않는다 — 위 일반 업로드 경로와 같은 이유.
if point_cloud_input_id is not None:
_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}",
)
return FileUploadResponse(project_id=str(project_id), files=[result])
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
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("B03 청크 업로드 최종 병합 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/upload-status/{session_id}", response_model=UploadStatusResponse)
async def get_project_upload_status(
project_id: UUID,
session_id: str,
) -> UploadStatusResponse | JSONResponse:
"""업로드 세션의 청크 완료 상태를 조회한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
upload_session = await get_upload_session(
connection,
project_id=project_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 lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception(
"B03 업로드 상태 조회 실패: project_id=%s session_id=%s",
project_id,
session_id,
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."},
)