refactor(B04): 지표면 라우터 700줄 분리 (경로·응답 불변)

B04_PreProcess_Router.py 718줄 -> 493줄.
- B04_PreProcess_Router_Status.py(221줄) — 분석 상태 조회·모델 프리뷰 엔드포인트.
  prefix 없이 만들고 본체가 include_router 로 실어 붙임(본체 prefix 를 그대로 상속).
- B04_PreProcess_Router_Progress.py(47줄) — progress.json 읽기·쓰기 헬퍼.
  본체와 상태 라우터가 함께 써야 해 순환 임포트를 피하려고 별도 모듈로 둠.
  본체는 write_surface_progress 를 계속 재노출(B03_FileInput_Service_WF1 이 참조).

검증: ruff check/format 통과. 라우터 경로 9개가 분리 전과 동일(중복 prefix 없음 확인).
백엔드 재시작(8001) 후 openapi 에 surface 경로 12개 그대로, 브라우저에서
/surface/status 200(진행률 100·모델 3), /surface/models 200,
/surface/models/415/preview 200 model/gltf-binary 7,946,756바이트.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:13:36 +09:00
co-authored by Claude Opus 5
parent 2975ea09c7
commit 03a193af70
3 changed files with 279 additions and 234 deletions
+9 -234
View File
@@ -7,10 +7,9 @@ from pathlib import Path
from typing import Any
from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_PreProcess.B04_PreProcess_Engine import (
@@ -30,6 +29,10 @@ from B04_PreProcess.B04_PreProcess_Repository import (
list_surface_models,
save_surface_analysis_to_db,
)
from B04_PreProcess.B04_PreProcess_Router_Progress import (
write_surface_progress,
)
from B04_PreProcess.B04_PreProcess_Router_Status import router as status_router
from B04_PreProcess.B04_PreProcess_Schema import (
SurfaceAnalyzeRequest,
SurfaceAnalyzeResponse,
@@ -45,9 +48,6 @@ from B04_PreProcess.B04_PreProcess_Schema import (
)
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
from common_util.common_util_auth import require_system_admin
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_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import (
get_surface_confirmation_params,
@@ -63,37 +63,10 @@ from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
POINT_CLOUD_SAMPLE_LIMIT = 500_000
# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다.
PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json")
def _progress_file_path(project_root: Path) -> Path:
return project_root.joinpath(*PROGRESS_FILE_RELATIVE)
def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None:
"""WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속)."""
try:
path = _progress_file_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{"progress_percent": percent, "current_stage": stage, "message": message},
)
except OSError:
logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True)
def read_surface_progress(project_root: Path) -> dict[str, Any] | None:
"""progress.json을 읽어 반환한다. 없거나 손상 시 None."""
path = _progress_file_path(project_root)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except (OSError, ValueError):
return None
# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내
# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04).
router.include_router(status_router)
@router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse)
@@ -518,201 +491,3 @@ async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsRespon
status_code=500,
content={"status": "error", "message": "지면 통계 조회 중 오류가 발생했습니다."},
)
@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": "프리뷰 파일 조회 중 오류가 발생했습니다."},
)
@@ -0,0 +1,47 @@
"""B04 지표면 분석 진행률 파일 입출력.
`B04_PreProcess_Router` 가 700줄을 넘겨 분리한 조각이다(2026-09-04). 라우터 본체와
상태 조회 라우터가 **같은 함수**를 써야 해서 여기에 둔다 — 순환 임포트를 피하려고
양쪽이 이 모듈을 바라본다. 동작·경로·값은 옮기기 전 그대로다.
"""
import json
import logging
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
logger = logging.getLogger(__name__)
# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다.
PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json")
def _progress_file_path(project_root: Path) -> Path:
return project_root.joinpath(*PROGRESS_FILE_RELATIVE)
def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None:
"""WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속)."""
try:
path = _progress_file_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{"progress_percent": percent, "current_stage": stage, "message": message},
)
except OSError:
logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True)
def read_surface_progress(project_root: Path) -> dict[str, Any] | None:
"""progress.json을 읽어 반환한다. 없거나 손상 시 None."""
path = _progress_file_path(project_root)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except (OSError, ValueError):
return None
@@ -0,0 +1,223 @@
"""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": "프리뷰 파일 조회 중 오류가 발생했습니다."},
)