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>
307 lines
13 KiB
Python
307 lines
13 KiB
Python
"""B03 파일 입력 FastAPI 라우터."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Engine import (
|
|
resolve_upload_destination,
|
|
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_input_readiness,
|
|
get_project_storage_relative_path,
|
|
list_incomplete_upload_sessions,
|
|
list_project_input_files,
|
|
)
|
|
from B03_FileInput.B03_FileInput_Router_Chunks import router as chunk_router
|
|
from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response
|
|
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
|
_REQUIRED_FILE_TYPES,
|
|
_complete_file_input_if_ready,
|
|
_missing_required_file_types,
|
|
_schedule_background_task,
|
|
_write_stage_metadata,
|
|
)
|
|
from B03_FileInput.B03_FileInput_Schema import (
|
|
FileUploadDescriptor,
|
|
FileUploadResponse,
|
|
UploadedFileResult,
|
|
UploadOverviewFile,
|
|
UploadOverviewResponse,
|
|
UploadOverviewSession,
|
|
)
|
|
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_json import atomic_write_json
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_workflow import load_project_workflow
|
|
from common_util.common_util_workflow_state import (
|
|
get_workflow_state,
|
|
)
|
|
from config.config_db import get_db_pool
|
|
from config.config_system import (
|
|
UPLOAD_MAX_FILES,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
|
|
|
|
# 청크 업로드 엔드포인트는 파일이 700줄을 넘어 떼어냈다(2026-09-04) — 경로는 그대로다.
|
|
router.include_router(chunk_router)
|
|
|
|
|
|
@router.post("/{project_id}/files", response_model=FileUploadResponse)
|
|
async def upload_project_files(
|
|
project_id: UUID,
|
|
files: list[UploadFile] = File(...),
|
|
las_free: bool = Form(False),
|
|
session: dict[str, Any] = Depends(verify_session),
|
|
) -> FileUploadResponse | JSONResponse:
|
|
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
|
if not files or len(files) > UPLOAD_MAX_FILES:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"status": "error",
|
|
"message": f"파일은 1~{UPLOAD_MAX_FILES}개까지 가능합니다.",
|
|
},
|
|
)
|
|
|
|
filenames = [upload.filename or "" for upload in files]
|
|
if len({filename.casefold() for filename in filenames}) != len(filenames):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."},
|
|
)
|
|
las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames)
|
|
# LAS 없는 설계(las_free)는 LAS를 **0개만** 받는다. 섞여 들어오면 되돌린다 —
|
|
# 올려 두면 전처리가 어느 쪽 경로인지 갈리지 않는다(2026-08-30 사용자 지시).
|
|
if las_free and las_count:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"status": "error",
|
|
"message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.",
|
|
},
|
|
)
|
|
if not las_free and las_count != 1:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"status": "error",
|
|
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
|
|
},
|
|
)
|
|
csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames)
|
|
if csv_count != 1:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"status": "error",
|
|
"message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.",
|
|
},
|
|
)
|
|
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
|
|
missing_required = _missing_required_file_types(request_file_types, las_free)
|
|
if missing_required:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"status": "error",
|
|
"message": f"B03 필수 입력 파일이 없습니다: {', '.join(missing_required)}",
|
|
},
|
|
)
|
|
|
|
pool = get_db_pool()
|
|
saved_paths: list[Path] = []
|
|
try:
|
|
results: list[UploadedFileResult] = []
|
|
point_cloud_input_id: int | None = None
|
|
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
|
|
await connection.begin()
|
|
try:
|
|
for upload in files:
|
|
preliminary = FileUploadDescriptor(
|
|
original_filename=upload.filename or "",
|
|
size_bytes=max(upload.size or 0, 1),
|
|
)
|
|
destination = resolve_upload_destination(project_root, preliminary)
|
|
written_bytes = await save_upload_stream(upload, destination)
|
|
saved_paths.append(destination)
|
|
descriptor = FileUploadDescriptor(
|
|
original_filename=preliminary.original_filename,
|
|
size_bytes=written_bytes,
|
|
)
|
|
metadata = await asyncio.to_thread(analyze_input_metadata, destination)
|
|
relative_path = destination.relative_to(project_root).as_posix()
|
|
file_type = destination.suffix.lower().lstrip(".")
|
|
crs_epsg = metadata.get("epsg")
|
|
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=written_bytes,
|
|
upload_by=None,
|
|
crs_epsg=int(crs_epsg) if crs_epsg is not None else None,
|
|
metadata=metadata,
|
|
)
|
|
results.append(
|
|
UploadedFileResult(
|
|
input_file_id=input_file_id,
|
|
original_filename=descriptor.original_filename,
|
|
file_type=file_type,
|
|
relative_path=relative_path,
|
|
size_bytes=written_bytes,
|
|
metadata=metadata,
|
|
)
|
|
)
|
|
point_cloud_input_id = await _complete_file_input_if_ready(
|
|
connection, project_id, las_free
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
|
|
stage_root = project_root / "B03_FileInput"
|
|
_write_stage_metadata(stage_root, project_id, results)
|
|
workflow_path = project_root / "workflow.json"
|
|
if not workflow_path.exists():
|
|
atomic_write_json(workflow_path, load_project_workflow(project_root))
|
|
# 업로드 직후 안내 메일은 보내지 않는다 — 초기 설계(B04~B06)까지 마친 뒤
|
|
# WF1 서비스가 통합 메일 한 통을 보낸다(2026-08-08 사용자 지시).
|
|
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=results)
|
|
except LookupError as exc:
|
|
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
|
except (OSError, ValueError) as exc:
|
|
for saved_path in saved_paths:
|
|
saved_path.unlink(missing_ok=True)
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
for saved_path in saved_paths:
|
|
saved_path.unlink(missing_ok=True)
|
|
logger.exception("B03 파일 업로드 처리 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "파일 업로드 처리 중 오류가 발생했습니다."},
|
|
)
|
|
finally:
|
|
for upload in files:
|
|
await upload.close()
|
|
|
|
|
|
def _parse_metadata(raw: Any) -> dict[str, Any] | None:
|
|
"""DB에 JSON 문자열로 저장된 분석 메타데이터를 dict로 돌린다(깨지면 생략)."""
|
|
if isinstance(raw, dict):
|
|
return raw
|
|
if not raw:
|
|
return None
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return parsed if isinstance(parsed, dict) else None
|
|
|
|
|
|
@router.get("/{project_id}/upload-overview", response_model=UploadOverviewResponse)
|
|
async def get_project_upload_overview(
|
|
project_id: UUID,
|
|
session: dict[str, Any] = Depends(verify_session),
|
|
) -> UploadOverviewResponse | JSONResponse:
|
|
"""B03 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부.
|
|
|
|
localStorage 기반 표시는 캐시를 지우거나 다른 PC로 가면 사라진다(2026-08-04 사용자
|
|
보고). 화면은 진입 시 이 응답을 정본으로 삼고 localStorage는 보조로만 쓴다.
|
|
"""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
files = await list_project_input_files(connection, project_id)
|
|
sessions = await list_incomplete_upload_sessions(connection, project_id)
|
|
file_types, point_cloud_id, _route_csv_id = await get_project_input_readiness(
|
|
connection, project_id
|
|
)
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
state = await get_workflow_state(cursor, str(project_id))
|
|
stages = (state or {}).get("stages") or []
|
|
analysis_complete = any(
|
|
int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE"
|
|
for stage in stages
|
|
)
|
|
# LAS 없는 설계로 stage 0을 마친 프로젝트는 LAS가 없어도 필수 충족으로 본다.
|
|
stage0_complete = any(
|
|
int(stage.get("stage_no", -1)) == 0 and str(stage.get("state")) == "COMPLETE"
|
|
for stage in stages
|
|
)
|
|
return UploadOverviewResponse(
|
|
files=[
|
|
UploadOverviewFile(
|
|
input_file_id=int(row["id"]),
|
|
file_type=str(row["file_type"]),
|
|
original_filename=str(row["original_filename"]),
|
|
file_size_mb=float(row["file_size_mb"] or 0.0),
|
|
status=str(row["status"]),
|
|
uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None,
|
|
relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None),
|
|
metadata=_parse_metadata(row.get("metadata")),
|
|
)
|
|
for row in files
|
|
],
|
|
pending_sessions=[
|
|
UploadOverviewSession(
|
|
upload_session_id=str(row["id"]),
|
|
original_filename=str(row["original_filename"]),
|
|
file_size_bytes=int(row["file_size_bytes"]),
|
|
total_chunks=int(row["total_chunks"]),
|
|
completed_chunks=int(row["completed_chunks"]),
|
|
progress_percent=round(
|
|
100.0 * int(row["completed_chunks"]) / max(1, int(row["total_chunks"])), 1
|
|
),
|
|
updated_at=str(row["updated_at"]) if row.get("updated_at") else None,
|
|
)
|
|
for row in sessions
|
|
],
|
|
required_complete=_REQUIRED_FILE_TYPES <= file_types
|
|
and (point_cloud_id is not None or stage0_complete),
|
|
analysis_complete=analysis_complete,
|
|
)
|
|
except Exception:
|
|
logger.exception("B03 업로드 현황 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "업로드 현황 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/workflow-state")
|
|
async def get_project_workflow_state(project_id: str):
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
|
state = await get_workflow_state(cursor, project_id)
|
|
return {"status": "success", "workflow_state": state}
|