feat(B03): 같은 파일 재업로드는 전송 생략, 다른 내용이면 덮어쓰기 (E2E 결함 2)
사용자 결정(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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ChunkSessionCreateResponse> {
|
||||
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<ChunkSessionCreateResponse>(response);
|
||||
@@ -121,6 +125,7 @@ export async function finalizeUploadSession(
|
||||
sessionId: string,
|
||||
totalChunks: number,
|
||||
completeUpload: boolean,
|
||||
fingerprint?: string | null,
|
||||
): Promise<FileUploadResponse> {
|
||||
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<FileUploadResponse>(response);
|
||||
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user