"""B04 지표면 분석 **상태 조회·모델 프리뷰** 라우터. `B04_PreProcess_Router` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). 경로·응답·로직은 옮기기 전 그대로이고, 본체가 `include_router` 로 이 라우터를 그대로 실어 붙인다. """ import logging from pathlib import Path from uuid import UUID import aiomysql from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress from common_util.common_util_http_cache import cached_file_response from common_util.common_util_initial_snapshot import is_designing from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool logger = logging.getLogger(__name__) # prefix 는 본체(`_Router`)가 `include_router` 할 때 붙는다 — 여기서 또 주면 # 경로가 `/api/projects/api/projects/...` 로 겹친다. router = APIRouter(tags=["B04 Surface Analysis"]) @router.get("/{project_id}/surface/status") async def get_wf1_analysis_status(project_id: UUID) -> dict: """WF1 분석 상태를 조회한다.""" pool = get_db_pool() try: async with pool.acquire() as connection: async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """ SELECT state, progress_percent, message, (SELECT COUNT(*) FROM surface_models WHERE project_id = %s) as model_count FROM project_workflow_stages WHERE project_id = %s AND stage_no = 1 """, (str(project_id), str(project_id)), ) row = await cursor.fetchone() # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) if not row: await cursor.execute( """ SELECT p.status as project_status, COUNT(sm.id) as model_count FROM projects p LEFT JOIN surface_models sm ON sm.project_id = p.id WHERE p.id = %s AND p.deleted_at IS NULL GROUP BY p.id, p.status """, (str(project_id),), ) fallback_row = await cursor.fetchone() if not fallback_row: return JSONResponse( status_code=404, content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, ) model_count = int(fallback_row["model_count"]) project_status = str(fallback_row.get("project_status") or "NEW") if project_status == "WF1_FAILED": state = "FAILED" progress_percent = 0 message = "WF1 분석에 실패했습니다." elif model_count > 0 or project_status == "WF1_COMPLETE": state = "COMPLETE" progress_percent = 100 message = "WF1 분석이 완료되었습니다." elif project_status == "WF1_ANALYZING": state = "IN_PROGRESS" progress_percent = 30 message = "WF1 분석이 진행 중입니다." else: state = "NOT_STARTED" progress_percent = 0 message = "WF1 분석 대기 중입니다." else: state = row["state"] progress_percent = row["progress_percent"] message = row["message"] or "" model_count = int(row["model_count"]) if state == "FAILED": status = "failed" current_stage = "failed" if not message: message = "WF1 분석에 실패했습니다." elif state == "COMPLETE": status = "completed" progress_percent = 100 current_stage = "completed" if not message: message = "WF1 분석이 완료되었습니다." elif state == "IN_PROGRESS": status = "in_progress" current_stage = "surface_analysis" if not message: message = "WF1 분석이 진행 중입니다." # 진행률 파일이 있으면 실제 단계별 진행률로 대체 try: async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) if progress: progress_percent = int(progress.get("progress_percent", progress_percent)) current_stage = str(progress.get("current_stage", current_stage)) message = str(progress.get("message", message)) except LookupError: pass else: status = "pending" progress_percent = 0 current_stage = "pending" if not message: message = "WF1 분석 대기 중입니다." # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. try: async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): status = "in_progress" current_stage = "initial_design" message = "초기 설계를 계산하는 중입니다." except (LookupError, OSError, ValueError): pass return { "project_id": str(project_id), "status": status, "model_count": model_count, "progress_percent": progress_percent, "current_stage": current_stage, "message": message, } except Exception: logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, ) @router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) async def get_surface_model_preview( request: Request, project_id: UUID, model_id: int, smooth: bool = False, ) -> Response | JSONResponse: """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" pool = get_db_pool() try: async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) async with connection.cursor() as cursor: await cursor.execute( """ SELECT model_type, model_file_path FROM surface_models WHERE id = %s AND project_id = %s """, (model_id, str(project_id)), ) row = await cursor.fetchone() if not row: return JSONResponse( status_code=404, content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, ) model_type, model_file_path = row[0], row[1] project_root = Path(resolve_stored_project_path(stored_path)) if not model_file_path: return JSONResponse( status_code=404, content={"status": "error", "message": "모델 파일 경로가 없습니다."}, ) model_path = project_root / model_file_path models_dir = model_path.parent stem = model_path.stem ext = "ply" if model_type == "meshfree" else "glb" if smooth and model_type in ("dtm", "tin"): preview_filename = f"{stem}_smooth_preview.glb" else: preview_filename = f"{stem}_preview.{ext}" preview_path = models_dir / preview_filename if not preview_path.is_file(): return JSONResponse( status_code=404, content={ "status": "error", "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", }, ) media_type = "application/octet-stream" if ext == "glb": media_type = "model/gltf-binary" elif ext == "ply": media_type = "application/ply" # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). return cached_file_response(request, preview_path, media_type, preview_filename) except Exception: logger.exception( "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id ) return JSONResponse( status_code=500, content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, )