feat(B03): 새 자료로 갈기 전에 「지우고 진행할까」 확인 한 단계 신설

업로드 하나가 그 프로젝트의 설계 산출물·초기값 스냅숏을 되돌릴 수 없게 지운다.
창 넷이 한 프로젝트를 볼 수 있어 말없이 지우면 남의 작업이 사라진다.

- `describe_existing_outputs`: 지워질 것을 사람 말로 낸다(전처리·노선·횡단·도면·
  수량·원가·초기값). 빈 폴더는 세지 않는다.
- 지울 것이 있는데 확인이 없으면 409 `confirm_required` — 무엇이 지워지는지 함께 준다.
- 세 갈래(직행·청크·보관함 연결) 전부에 `confirm_replace` 를 붙임.
- ⚠ 기본값은 확인 켬(True) — 자동 절차(체인·스크립트)는 물음에 안 걸린다.
  사람이 올리는 라우터만 False 로 불러 확인을 받는다.
- 화면: 409 를 받으면 「되돌릴 수 없습니다」와 지워질 목록을 보이고 한 번 묻는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 18:14:54 +09:00
co-authored by Claude Opus 5
parent d57c0f71ce
commit 09e1c983ae
7 changed files with 181 additions and 16 deletions
+31 -1
View File
@@ -51,9 +51,33 @@ export interface UploadStatusResponse {
completed_chunk_indexes: number[];
}
/**
* 「지우고 진행할까?」를 물어야 하는 상태(409). 서버가 무엇이 지워지는지 함께 준다.
* 되돌릴 수 없는 삭제라 사람이 한 번 보고 정한다(2026-09-08 사용자 지시).
*/
export class ReplaceOutputsConfirmRequired extends Error {
readonly targets: string[];
constructor(message: string, targets: string[]) {
super(message);
this.name = "ReplaceOutputsConfirmRequired";
this.targets = targets;
}
}
async function readJsonOrThrow<T>(response: Response): Promise<T> {
const payload = (await response.json()) as T & { message?: string };
const payload = (await response.json()) as T & {
message?: string;
confirm?: string;
targets?: string[];
};
if (!response.ok) {
if (response.status === 409 && payload.confirm === "replace_outputs") {
throw new ReplaceOutputsConfirmRequired(
payload.message ?? "이미 만들어 둔 결과가 지워집니다.",
payload.targets ?? [],
);
}
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload;
@@ -62,9 +86,11 @@ async function readJsonOrThrow<T>(response: Response): Promise<T> {
export async function uploadProjectFiles(
projectId: string,
files: readonly File[],
confirmReplace = false,
): Promise<FileUploadResponse> {
const formData = new FormData();
for (const file of files) formData.append("files", file, file.name);
if (confirmReplace) formData.append("confirm_replace", "true");
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
@@ -88,6 +114,7 @@ export async function createUploadSession(
fingerprint?: string | null,
completeUpload = false,
lasFree = false,
confirmReplace = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
@@ -100,6 +127,7 @@ export async function createUploadSession(
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
las_free: lasFree,
confirm_replace: confirmReplace,
}),
});
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
@@ -131,6 +159,7 @@ export async function finalizeUploadSession(
completeUpload: boolean,
fingerprint?: string | null,
lasFree = false,
confirmReplace = false,
): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
@@ -142,6 +171,7 @@ export async function finalizeUploadSession(
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
las_free: lasFree,
confirm_replace: confirmReplace,
}),
});
return await readJsonOrThrow<FileUploadResponse>(response);
+10 -1
View File
@@ -59,6 +59,10 @@ from B03_FileInput.B03_FileInput_Router_Helpers import (
from B03_FileInput.B03_FileInput_Router_Helpers import (
_SHAPEFILE_REQUIRED_TYPES as _SHAPEFILE_REQUIRED_TYPES,
)
from B03_FileInput.B03_FileInput_Router_Helpers import (
OutputsWouldBeDiscarded,
_confirm_replace_response,
)
from B03_FileInput.B03_FileInput_Router_Helpers import (
_already_uploaded as _already_uploaded,
)
@@ -132,6 +136,7 @@ async def upload_project_files(
project_id: UUID,
files: list[UploadFile] = File(...),
las_free: bool = Form(False),
confirm_replace: bool = Form(False),
session: dict[str, Any] = Depends(verify_session),
) -> FileUploadResponse | JSONResponse:
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
@@ -258,7 +263,7 @@ async def upload_project_files(
)
)
point_cloud_input_id = await _complete_file_input_if_ready(
connection, project_id, las_free
connection, project_id, las_free, confirm_replace
)
await connection.commit()
except Exception:
@@ -282,6 +287,10 @@ async def upload_project_files(
task_name=f"b04-preprocess-auto-{project_id}",
)
return FileUploadResponse(project_id=str(project_id), files=results)
except OutputsWouldBeDiscarded as exc:
for saved_path in saved_paths:
saved_path.unlink(missing_ok=True)
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -110,6 +110,7 @@ async def create_project_upload_session(
connection,
project_id,
payload.las_free,
payload.confirm_replace,
)
await connection.commit()
except Exception:
@@ -144,6 +145,8 @@ async def create_project_upload_session(
chunk_size_bytes=chunk_size_bytes,
total_chunks=total_chunks,
)
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -202,6 +205,8 @@ async def upload_project_chunk(
total_chunks=int(upload_session["total_chunks"]),
chunk_hash=chunk_hash,
)
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -300,6 +305,7 @@ async def finalize_project_upload(
connection,
project_id,
payload.las_free,
payload.confirm_replace,
)
await connection.commit()
except Exception:
@@ -329,6 +335,8 @@ async def finalize_project_upload(
task_name=f"b04-preprocess-auto-{project_id}",
)
return FileUploadResponse(project_id=str(project_id), files=[result])
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -373,6 +381,8 @@ async def get_project_upload_status(
completed_chunks=len(completed_indexes),
completed_chunk_indexes=completed_indexes,
)
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -12,6 +12,7 @@ from typing import Any
from uuid import UUID
import aiomysql
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Email import (
send_file_upload_complete_email,
@@ -40,6 +41,61 @@ logger = logging.getLogger(__name__)
_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요."
# 새 자료를 받으면 옛 산출물을 지운다 — 지워질 것이 있으면 **먼저 묻는다**(2026-09-08).
# 창 넷이 한 프로젝트를 함께 볼 수 있어, 말없이 지우면 남이 며칠 만든 설계가 사라진다.
# 되돌릴 길이 없다(초기값 스냅숏까지 함께 버린다).
_OUTPUT_STAGE_LABELS: tuple[tuple[str, str], ...] = (
("B04_PreProcess", "지표면·전처리 결과"),
("B05_Profile", "노선·종단 설계"),
("B06_Section", "횡단 설계"),
("B07_DesignDetail", "도면"),
("B08_Quantity", "수량 산출"),
("B09_Estimation", "원가·내역"),
)
class OutputsWouldBeDiscarded(Exception):
"""이미 있는 설계 산출물을 지워야 새 자료를 받을 수 있는 상태.
라우터가 409 로 돌려주고, 사람이 「지우고 진행」을 고르면 `confirm_replace=True`
로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다.
"""
def __init__(self, targets: list[str]) -> None:
self.targets = targets
super().__init__("설계 산출물이 있어 확인이 필요합니다.")
def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse:
"""「지우고 진행할까?」를 사람에게 묻는 409 — 무엇이 지워지는지 함께 알린다."""
return JSONResponse(
status_code=409,
content={
"status": "confirm_required",
"confirm": "replace_outputs",
"message": (
"이 프로젝트에는 이미 만들어 둔 결과가 있습니다. 새 자료로 갈면 "
"아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets)
),
"targets": exc.targets,
},
)
def describe_existing_outputs(project_root: Path) -> list[str]:
"""새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록."""
targets: list[str] = []
for stage, label in _OUTPUT_STAGE_LABELS:
stage_root = project_root / stage
if not stage_root.is_dir():
continue
if any(entry.is_file() for entry in stage_root.rglob("*")):
targets.append(label)
if (project_root / "initial_snapshot").is_dir():
targets.append("초기값 스냅숏(되돌리기의 기준)")
return targets
_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"})
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
@@ -149,7 +205,13 @@ async def _complete_file_input_if_ready(
connection: aiomysql.Connection,
project_id: UUID,
las_free: bool = False,
confirm_replace: bool = True,
) -> int:
"""새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다.
기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다.
사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시).
"""
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
connection, project_id
)
@@ -172,6 +234,11 @@ async def _complete_file_input_if_ready(
gap_message = merge_gap_error(terrain_paths)
if gap_message:
raise ValueError(gap_message)
# ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다.
if not confirm_replace:
targets = describe_existing_outputs(project_root)
if targets:
raise OutputsWouldBeDiscarded(targets)
clear_designing(project_root)
discard_initial_snapshot(project_root)
await purge_project_outputs(connection, str(project_id), project_root)
@@ -86,6 +86,11 @@ 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 B03_FileInput.B03_FileInput_Router_Helpers import (
OutputsWouldBeDiscarded,
_confirm_replace_response,
describe_existing_outputs,
)
from common_util.common_util_project_reset import purge_project_outputs
from common_util.common_util_storage import (
resolve_stored_project_path,
@@ -359,6 +364,7 @@ async def upload_batch_files(
async def attach_temp_batch(
project_id: UUID,
batch_id: str,
confirm_replace: bool = False,
session: dict[str, Any] = Depends(verify_session),
) -> TempBatchAttachResponse | JSONResponse:
"""보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다.
@@ -403,6 +409,11 @@ async def attach_temp_batch(
# 자료가 갈리므로 옛 계산 결과(파일 + DB)를 먼저 지운다 — 남겨 두면 아직 다시
# 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
# ⚠ 되돌릴 수 없다 — 지울 것이 있으면 먼저 묻는다(2026-09-08).
if not confirm_replace:
targets = describe_existing_outputs(project_root)
if targets:
return _confirm_replace_response(OutputsWouldBeDiscarded(targets))
async with pool.acquire() as connection:
await connection.begin()
try:
+6
View File
@@ -62,6 +62,9 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
fingerprint: str | None = Field(default=None, max_length=128)
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
class ChunkSessionCreateResponse(BaseModel):
@@ -103,6 +106,9 @@ class UploadFinalizeRequest(BaseModel):
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
fingerprint: str | None = Field(default=None, max_length=128)
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
class UploadStatusResponse(BaseModel):
+34 -2
View File
@@ -15,6 +15,7 @@ import {
checkWF1AnalysisStatus,
createUploadSession,
finalizeUploadSession,
ReplaceOutputsConfirmRequired,
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
@@ -77,6 +78,31 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
* 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다.
* 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다.
*/
/**
* 「이미 만들어 둔 결과가 지워집니다」를 사람에게 한 번 묻는다.
* 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시).
* 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다.
*/
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
try {
return await run(false);
} catch (error) {
if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error;
const detail = error.targets.length
? `
지워지는 것: ${error.targets.join(" · ")}`
: "";
const agreed = window.confirm(
`${error.message}${detail}
되돌릴 수 없습니다. 계속할까요?`,
);
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
return await run(true);
}
}
export async function uploadOneFile(
projectId: string,
state: FileSlotState,
@@ -97,13 +123,16 @@ export async function uploadOneFile(
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
let session = state.uploadSessionId;
if (!session) {
const created = await createUploadSession(
const created = await askReplaceOutputs((confirmReplace) =>
createUploadSession(
projectId,
file,
chunkSizeBytes,
fingerprint,
completeUpload,
lasFree,
confirmReplace,
),
);
if (created.already_uploaded) {
state.progressBytes = file.size;
@@ -155,13 +184,16 @@ export async function uploadOneFile(
}
}
const response = await finalizeUploadSession(
const response = await askReplaceOutputs((confirmReplace) =>
finalizeUploadSession(
projectId,
session,
totalChunks,
completeUpload,
fingerprint,
lasFree,
confirmReplace,
),
);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {