feat(B03): 자료 교체 시 옛 산출물 정리 + 분석 중 업로드 차단 (E2E 결함 3·6 서버측)

사용자 결정(2026-08-08): 새 자료를 올리면 재계산해 덮어쓰고, 화면은 불러올 자료가 없으면
대시보드로 보낸다. 그러려면 자료가 갈리는 순간 옛 계산 결과가 남아 있으면 안 된다.

- common_util_project_reset 신설: 단계별 산출물 폴더(B04~B09)와 산출물 DB 레코드
  (surface_models·routes·route_points·route_statistics·longitudinal_sections·
  cross_sections·structures·quantity_items·outputs·processed_point_cloud)를 함께 지운다.
  **B03_FileInput(업로드 원본)은 지우지 않는다** — 같은 파일인지 가리는 중복 검사가 쓴다.
- 직접 업로드 완료·보관함 연결 양쪽에서 정리를 호출하고, 진행 단계도 1단계 이후를
  NOT_STARTED로 되돌린다(reset_stages_after_input_change).
- is_analysis_running(): 전처리가 도는 중이면 업로드 세션 생성과 보관함 연결을 409로
  막는다. 화면 버튼 잠금은 새로고침·다른 탭으로 우회되므로 서버에도 문을 단다.
- fail_stage 아래 있던 리비전(번호표) 안은 폐기 — 사용자가 "산출물이 없으면 대시보드"
  방식으로 정리했다.

곁들여: os.replace 공유 위반 재시도(replace_with_retry)
  진행률 파일은 서버가 쓰는 동안 화면이 계속 읽어 WinError 5가 났고, 전처리
  structured.npz 교체에서는 같은 이유로 분석이 통째로 죽었다(결함 3의 사망 원인).
  짧게 여러 번 다시 시도하도록 바꿨다.

검증(실서버 f45243b3, 계획노선 CSV 재업로드로 자료 교체):
  전처리 결과 112개 -> 재분석분만, 노선 2->0, 횡단 20->0,
  DB 지표면 15->0 / 노선 1->0 / 횡단 19->0, 업로드 원본 6개는 그대로.
  진행 단계가 초기로 돌아가고 재분석이 자동 시작됨.
  교체 재시도는 읽는 쪽이 파일을 잡고 있는 상황을 만들어 성공 확인.
ruff format·check 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 19:41:51 +09:00
co-authored by Claude Opus 5
parent 1f119f9845
commit 37b4d41e0b
6 changed files with 218 additions and 6 deletions
+28 -2
View File
@@ -12,6 +12,7 @@ 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
@@ -72,11 +73,16 @@ from B03_FileInput.B03_FileInput_Schema_Temp import (
)
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_project_reset import purge_project_outputs
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 common_util.common_util_workflow_state import (
complete_stage,
is_analysis_running,
reset_stages_after_input_change,
)
from config.config_db import get_db_pool
from config.config_system import (
TEMP_UPLOAD_RETENTION_DAYS,
@@ -84,6 +90,8 @@ from config.config_system import (
UPLOAD_MAX_FILES,
)
_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 다시 시도해 주세요."
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/temp-uploads", tags=["B03 Temp Upload"])
attach_router = APIRouter(prefix="/api/projects", tags=["B03 Temp Upload"])
@@ -611,6 +619,12 @@ async def attach_temp_batch(
pool = get_db_pool()
try:
async with pool.acquire() as connection:
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},
)
batch = await get_temp_batch(connection, batch_id=batch_id, user_id=user_id)
if str(batch["status"]) == "linked":
return JSONResponse(
@@ -634,6 +648,17 @@ async def attach_temp_batch(
batch_root = Path(resolve_temp_batch_path(user_id, batch_id, create=False))
project_root = Path(resolve_stored_project_path(stored_path))
# 자료가 갈리므로 옛 계산 결과(파일 + DB)를 먼저 지운다 — 남겨 두면 아직 다시
# 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
async with pool.acquire() as connection:
await connection.begin()
try:
await purge_project_outputs(connection, str(project_id), project_root)
await connection.commit()
except Exception:
await connection.rollback()
raise
moved: list[dict[str, Any]] = []
for item in files:
source = batch_root / str(item["relative_path"])
@@ -668,7 +693,8 @@ async def attach_temp_batch(
)
if str(item["file_type"]).lower() in _POINT_CLOUD_FILE_TYPES:
point_cloud_input_id = input_file_id
async with connection.cursor() as cursor:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await reset_stages_after_input_change(cursor, str(project_id))
await complete_stage(cursor, str(project_id), 0)
await mark_temp_batch_linked(
connection, batch_id=batch_id, project_id=str(project_id)