From d935013ffa102b598718da774a0f0e4fb6529dee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 8 Aug 2026 20:11:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(B03):=20=EA=B0=99=EC=9D=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=AC=EC=97=85=EB=A1=9C=EB=93=9C=EB=8A=94=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=83=9D=EB=9E=B5,=20=EB=8B=A4=EB=A5=B8?= =?UTF-8?q?=20=EB=82=B4=EC=9A=A9=EC=9D=B4=EB=A9=B4=20=EB=8D=AE=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20(E2E=20=EA=B2=B0=ED=95=A8=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 결정(2026-08-08): 복수 파일을 허용하고 중복 기준은 파일명으로 둔다. 같은 이름으로 같은 내용이 다시 들어오면 덮어쓰지 말고 건너뛰고, 내용이 다르면 덮어쓴다. 파일 지문(부분 샘플링) - B03_FileInput_Fingerprint.ts: 파일 크기 + 앞·중간·끝 8MB를 이어 SHA-256. 24MB만 읽어 1~2초면 끝난다. 전체 읽기(1.7GB, 10~30초)와 견줘 실용적이고, 자리를 앞·중간·끝으로 흩어 놓아 머리말만 같은 파일도 갈린다. 한계는 주석에 적었다. - 화면이 업로드 세션 생성 요청에 지문을 실어 보내고, 서버가 같은 이름의 최신 입력 파일 메타데이터에 적힌 지문과 견준다. 같으면 already_uploaded=true로 답해 **전송 자체를** 건너뛴다(1.7GB면 3~5분 절약). 지문이 없거나 다르면 그냥 올린다 — 애매하면 올리는 쪽. - 완료 요청에도 지문을 실어 input_files.metadata에 남긴다. upload_sessions에 컬럼을 더하지 않으려는 선택이라 DB 스키마 변경이 없다. 옛 행 정리 - supersede_previous_input_files(): 같은 이름의 이전 행을 SUPERSEDED로 내린다. 조회는 UPLOADED/PROCESSED만 보므로 목록·분석에서 자동으로 빠지고, 행은 이력으로 남는다. - 직접 업로드 완료와 보관함 연결 양쪽에 적용. 검증(실서버 f45243b3) - 같은 파일 재요청 → already_uploaded=true, 세션 미발급. - 지문이 다르면 → 세션 발급(정상 업로드 경로). - 옛 행이 SUPERSEDED로 내려가는 것 DB에서 확인. - 화면 코드(B03_FileInput_Fingerprint.ts)를 그대로 실행해 만든 지문과 서버측 검증 스크립트의 지문이 20MB 표본에서 완전히 일치(f281fd08…93e4). typecheck·ruff·prettier 통과, 정적 번들 재빌드. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 6 +++ B03_FileInput/B03_FileInput_Fingerprint.ts | 51 ++++++++++++++++++ B03_FileInput/B03_FileInput_Repository.py | 50 ++++++++++++++++++ B03_FileInput/B03_FileInput_Router.py | 60 ++++++++++++++++++++++ B03_FileInput/B03_FileInput_Router_Temp.py | 8 +++ B03_FileInput/B03_FileInput_Schema.py | 8 +++ B03_FileInput/B03_FileInput_UI_Upload.ts | 26 ++++++++-- 7 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_Fingerprint.ts diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 5ed7a30c..0d48f9f1 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -26,6 +26,8 @@ export interface ChunkSessionCreateResponse { chunk_size_bytes: number; total_chunks: number; completed_chunks: number; + /** 같은 내용이 이미 올라와 있어 전송을 건너뛰어도 되는지. */ + already_uploaded?: boolean; } export interface ChunkUploadResponse { @@ -83,6 +85,7 @@ export async function createUploadSession( projectId: string, file: File, chunkSizeBytes: number, + fingerprint?: string | null, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, { method: "POST", @@ -92,6 +95,7 @@ export async function createUploadSession( original_filename: file.name, size_bytes: file.size, chunk_size_bytes: chunkSizeBytes, + fingerprint: fingerprint ?? null, }), }); return await readJsonOrThrow(response); @@ -121,6 +125,7 @@ export async function finalizeUploadSession( sessionId: string, totalChunks: number, completeUpload: boolean, + fingerprint?: string | null, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { method: "POST", @@ -130,6 +135,7 @@ export async function finalizeUploadSession( session_id: sessionId, total_chunks: totalChunks, complete_upload: completeUpload, + fingerprint: fingerprint ?? null, }), }); return await readJsonOrThrow(response); diff --git a/B03_FileInput/B03_FileInput_Fingerprint.ts b/B03_FileInput/B03_FileInput_Fingerprint.ts new file mode 100644 index 00000000..8f51f8bc --- /dev/null +++ b/B03_FileInput/B03_FileInput_Fingerprint.ts @@ -0,0 +1,51 @@ +/* ============================================================================= + * B03_FileInput_Fingerprint.ts + * 파일 지문 — 같은 파일을 다시 올리는지 **전송 전에** 가린다. + * + * 라이다 원본은 1.7GB가 넘어 한 번 올리는 데 몇 분이 걸린다. 같은 파일을 다시 고른 경우 + * 그 시간을 통째로 버리게 되므로, 올리기 전에 서버에 "이 파일 이미 있어?"를 물어본다. + * + * 전체를 읽어 지문을 만들면 가장 정확하지만 1.7GB를 다 읽어야 한다. 그래서 **크기 + 앞·중간· + * 끝 8MB**만 읽어 지문을 만든다(2026-08-08 사용자 결정). 24MB만 읽으므로 1~2초면 끝나고, + * 자리를 앞·중간·끝으로 흩어 놓아 "머리말만 같은 파일"도 걸러진다. + * + * 한계: 크기가 같고 세 구간까지 같은데 그 사이만 다른 파일은 같다고 볼 수 있다. 현실에서는 + * 사실상 생기지 않지만, 완벽이 필요해지면 전체 읽기로 바꾸면 된다(파일당 10~30초). + * ========================================================================== */ + +/** 지문에 쓰는 구간 크기(8MB). 앞·중간·끝에서 이만큼씩 읽는다. */ +const SAMPLE_BYTES = 8 * 1024 * 1024; + +function toHex(buffer: ArrayBuffer): string { + return [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * 파일 지문을 만든다. 브라우저가 지원하지 않으면(보안 컨텍스트가 아니면) `null` — + * 그때는 지문 없이 그냥 올린다(애매하면 올리는 쪽이 안전하다). + */ +export async function fileFingerprint(file: File): Promise { + if (!crypto?.subtle) return null; + try { + const middleStart = Math.max(0, Math.floor(file.size / 2) - Math.floor(SAMPLE_BYTES / 2)); + const parts = [ + file.slice(0, Math.min(SAMPLE_BYTES, file.size)), + file.slice(middleStart, Math.min(middleStart + SAMPLE_BYTES, file.size)), + file.slice(Math.max(0, file.size - SAMPLE_BYTES)), + ]; + const chunks = await Promise.all(parts.map((part) => part.arrayBuffer())); + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + // 크기를 함께 섞는다 — 구간이 같아도 길이가 다르면 다른 파일이다. + const header = new TextEncoder().encode(`${file.size}:`); + const merged = new Uint8Array(header.byteLength + total); + merged.set(header, 0); + let offset = header.byteLength; + for (const chunk of chunks) { + merged.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } + return toHex(await crypto.subtle.digest("SHA-256", merged)); + } catch { + return null; + } +} diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index d963108f..e8abf383 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -338,3 +338,53 @@ async def list_incomplete_upload_sessions( ) rows = await cursor.fetchall() return [dict(row) for row in rows] + + +async def find_input_file_by_name( + connection: aiomysql.Connection, + project_id: UUID, + original_filename: str, +) -> dict[str, Any] | None: + """같은 이름으로 등록된 최신 입력 파일 1건. 없으면 None. + + 같은 파일을 다시 올렸는지 가리는 데 쓴다 — 중복 판정 기준은 파일명이고, 내용이 같은지는 + 이 행의 메타데이터에 적힌 지문으로 본다(2026-08-08 사용자 결정). + """ + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, original_filename, file_size_mb, metadata + FROM input_files + WHERE project_id = %s AND original_filename = %s + AND status IN ('UPLOADED', 'PROCESSED') + ORDER BY id DESC + LIMIT 1 + """, + (str(project_id), original_filename), + ) + row = await cursor.fetchone() + return dict(row) if row else None + + +async def supersede_previous_input_files( + connection: aiomysql.Connection, + project_id: UUID, + original_filename: str, + keep_input_file_id: int, +) -> int: + """같은 이름의 옛 행을 `SUPERSEDED`로 내린다. 내린 건수를 돌려준다. + + 조회 쿼리들이 `UPLOADED`/`PROCESSED`만 보므로, 이렇게만 해도 목록·분석에서 빠진다. + 행을 지우지 않는 이유는 언제 무엇이 교체됐는지 추적할 근거를 남기기 위해서다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE input_files + SET status = 'SUPERSEDED' + WHERE project_id = %s AND original_filename = %s AND id <> %s + AND status IN ('UPLOADED', 'PROCESSED') + """, + (str(project_id), original_filename, keep_input_file_id), + ) + return cursor.rowcount diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 5728b6e9..9d7bcad9 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -26,6 +26,7 @@ from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, create_upload_session, + find_input_file_by_name, get_project_input_readiness, get_project_storage_relative_path, get_upload_session, @@ -34,6 +35,7 @@ from B03_FileInput.B03_FileInput_Repository import ( list_project_input_files, mark_upload_session_completed, mark_upload_session_failed, + supersede_previous_input_files, upsert_upload_chunk, ) from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response @@ -103,6 +105,50 @@ def _require_complete_file_set(file_types: set[str]) -> None: raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}") +def _stored_fingerprint(metadata: Any) -> str | None: + """입력 파일 메타데이터에 적어 둔 지문을 꺼낸다.""" + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except (TypeError, ValueError): + return None + if not isinstance(metadata, dict): + return None + value = metadata.get("fingerprint") + return str(value) if value else None + + +async def _already_uploaded( + connection: aiomysql.Connection, + project_id: UUID, + payload: ChunkSessionCreateRequest, +) -> ChunkSessionCreateResponse | None: + """같은 이름으로 **같은 내용**이 이미 올라와 있으면 전송을 건너뛰라는 응답을 만든다. + + 1.7GB를 다 받은 뒤에 비교하면 아낄 게 없으므로, 세션을 만들기 전에 화면이 보내 준 + 지문으로 가린다. 지문이 없거나 다르면 그냥 올린다 — 애매하면 올리는 쪽이 안전하다. + """ + if not payload.fingerprint: + return None + existing = await find_input_file_by_name(connection, project_id, payload.original_filename) + if not existing or _stored_fingerprint(existing.get("metadata")) != payload.fingerprint: + return None + logger.info( + "B03 같은 파일 재업로드 — 전송 생략: project_id=%s file=%s", + project_id, + payload.original_filename, + ) + return ChunkSessionCreateResponse( + project_id=str(project_id), + upload_session_id="", + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=payload.chunk_size_bytes, + total_chunks=0, + already_uploaded=True, + ) + + async def _complete_file_input_if_ready( connection: aiomysql.Connection, project_id: UUID, @@ -401,6 +447,9 @@ async def create_project_upload_session( status_code=409, content={"status": "error", "message": _ANALYSIS_RUNNING_MESSAGE}, ) + skipped = await _already_uploaded(connection, project_id, payload) + if skipped is not None: + return skipped await create_upload_session( connection, session_id=session_id, @@ -540,6 +589,10 @@ async def finalize_project_upload( payload.total_chunks, ) metadata = await asyncio.to_thread(analyze_input_metadata, final_path) + # 다음에 같은 파일이 올라오면 전송을 건너뛸 수 있도록 지문을 함께 남긴다. + fingerprint = payload.fingerprint or None + if fingerprint: + metadata = {**metadata, "fingerprint": fingerprint} relative_path = final_path.relative_to(project_root).as_posix() file_type = final_path.suffix.lower().lstrip(".") crs_epsg = metadata.get("epsg") @@ -557,6 +610,13 @@ async def finalize_project_upload( crs_epsg=int(crs_epsg) if crs_epsg is not None else None, metadata=metadata, ) + # 같은 이름의 옛 행은 내려 둔다 — 목록·분석이 최신 1건만 보게 한다. + await supersede_previous_input_files( + connection, + project_id, + descriptor.original_filename, + input_file_id, + ) await mark_upload_session_completed(connection, session_id=payload.session_id) if payload.complete_upload: point_cloud_input_id = await _complete_file_input_if_ready( diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py index 4078c170..a190a34b 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp.py +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -31,6 +31,7 @@ from B03_FileInput.B03_FileInput_Repository import ( list_completed_chunk_indexes, mark_upload_session_completed, mark_upload_session_failed, + supersede_previous_input_files, upsert_upload_chunk, ) from B03_FileInput.B03_FileInput_Repository_Temp import ( @@ -691,6 +692,13 @@ async def attach_temp_batch( crs_epsg=item.get("crs_epsg"), metadata=metadata or {}, ) + # 같은 이름의 옛 행은 내려 둔다 — 목록·분석이 최신 1건만 보게 한다. + await supersede_previous_input_files( + connection, + project_id, + str(item["original_filename"]), + input_file_id, + ) if str(item["file_type"]).lower() in _POINT_CLOUD_FILE_TYPES: point_cloud_input_id = input_file_id async with connection.cursor(aiomysql.DictCursor) as cursor: diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 118e429f..9b656033 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -56,6 +56,9 @@ class ChunkSessionCreateRequest(FileUploadDescriptor): """청크 업로드 세션 생성 요청.""" chunk_size_bytes: int = Field(default=UPLOAD_CHUNK_SIZE_BYTES, gt=0) + # 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다. + # 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]). + fingerprint: str | None = Field(default=None, max_length=128) class ChunkSessionCreateResponse(BaseModel): @@ -69,6 +72,8 @@ class ChunkSessionCreateResponse(BaseModel): chunk_size_bytes: int total_chunks: int completed_chunks: int = 0 + # 이미 같은 내용이 올라와 있어 전송을 건너뛰어도 되는지. true면 세션을 만들지 않는다. + already_uploaded: bool = False class ChunkUploadResponse(BaseModel): @@ -90,6 +95,9 @@ class UploadFinalizeRequest(BaseModel): session_id: str = Field(min_length=1, max_length=36) total_chunks: int = Field(gt=0) complete_upload: bool = True + # 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이 + # 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택). + fingerprint: str | None = Field(default=None, max_length=128) class UploadStatusResponse(BaseModel): diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index 6ce27fd4..a2a292e6 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -9,6 +9,7 @@ import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend"; import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav"; import { createButton } from "@ui/ui_template_elements"; +import { fileFingerprint } from "./B03_FileInput_Fingerprint"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { checkWF1AnalysisStatus, @@ -105,9 +106,20 @@ export async function uploadOneFile( onProgress(); const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024; - const session = - state.uploadSessionId ?? - (await createUploadSession(projectId, file, chunkSizeBytes)).upload_session_id; + // 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다. + const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file); + let session = state.uploadSessionId; + if (!session) { + const created = await createUploadSession(projectId, file, chunkSizeBytes, fingerprint); + if (created.already_uploaded) { + state.progressBytes = file.size; + state.etaSeconds = 0; + state.uploadStatus = "completed"; + onProgress(); + return []; + } + session = created.upload_session_id; + } state.uploadSessionId = session; const totalChunks = Math.max(1, Math.ceil(file.size / chunkSizeBytes)); const storageKey = makeSessionKey(projectId, file); @@ -149,7 +161,13 @@ export async function uploadOneFile( } } - const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload); + const response = await finalizeUploadSession( + projectId, + session, + totalChunks, + completeUpload, + fingerprint, + ); localStorage.removeItem(storageKey); saveB03UploadedFile(projectId, { slot: state.slot,