Files
Aislo/B03_FileInput/B03_FileInput_Api_Fetch.ts
eomsangdonandClaude Opus 5 018ebe7f40 feat(B03): 업로드 잠금에 이름을 붙임 — 지금 누가 올리는 중인지 화면에 뜸
계획서 0-8 의 남은 하나. 서버는 분석이 도는 동안 새 자료를 이미 막고 있었고
(`is_analysis_running`), 없던 것은 **막힌 까닭을 사람에게 보이는 것**이었음.
여럿이 한 프로젝트를 보면 「왜 안 올라가지」가 됨.

- 분석을 띄우는 **경로 넷 모두** 시작한 사람을 `params.started_by` 로 담음.
- `analysis_lock_owner` 가 그 id 로 이름을 붙여 냄 — 옛 자료는 id 가 없어
  **이름 없이 잠금만** 돎(막는 것이 먼저).
- `/upload-overview` 응답에 `analysis_lock` 을 실어 B03 화면이 띠로 보임.

화면 실측 — 「엄상돈 님이 올린 자료를 분석하는 중입니다」 띠가 경고색으로 뜸(응답을
잠깐 가로채 확인하고 곧바로 되돌림). 지금 도는 분석이 없어 실제 잠금 화면은 다음 업로드 때 볼 것.
시험 넷 추가(도는 중 아님·이름 붙음·옛 자료·경로 넷 대조), 전체 1211 통과·실패 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 22:43:28 +09:00

259 lines
7.6 KiB
TypeScript

/* B03 다중 파일 업로드 API 클라이언트 */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
export interface UploadedFileResult {
input_file_id: number;
original_filename: string;
file_type: string;
relative_path: string;
size_bytes: number;
metadata: Record<string, unknown>;
}
export interface FileUploadResponse {
status: string;
project_id: string;
files: UploadedFileResult[];
}
export interface ChunkSessionCreateResponse {
status: string;
project_id: string;
upload_session_id: string;
original_filename: string;
file_size_bytes: number;
chunk_size_bytes: number;
total_chunks: number;
completed_chunks: number;
/** 같은 내용이 이미 올라와 있어 전송을 건너뛰어도 되는지. */
already_uploaded?: boolean;
}
export interface ChunkUploadResponse {
status: string;
upload_session_id: string;
chunk_index: number;
completed_chunks: number;
total_chunks: number;
chunk_hash: string;
}
export interface UploadStatusResponse {
status: string;
upload_session_id: string;
upload_status: string;
original_filename: string;
file_size_bytes: number;
chunk_size_bytes: number;
total_chunks: number;
completed_chunks: number;
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;
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;
}
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);
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
});
return await readJsonOrThrow<FileUploadResponse>(response);
} finally {
window.clearTimeout(timeoutId);
}
}
export async function createUploadSession(
projectId: string,
file: File,
chunkSizeBytes: number,
fingerprint?: string | null,
completeUpload = false,
lasFree = false,
confirmReplace = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_filename: file.name,
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
las_free: lasFree,
confirm_replace: confirmReplace,
}),
});
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
}
export async function uploadFileChunk(
projectId: string,
sessionId: string,
chunkIndex: number,
chunk: Blob,
): Promise<ChunkUploadResponse> {
const formData = new FormData();
formData.append("session_id", sessionId);
formData.append("chunk_index", String(chunkIndex));
formData.append("chunk_data", chunk, `${sessionId}-${chunkIndex}.chunk`);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/chunks`, {
method: "POST",
credentials: "include",
body: formData,
});
return await readJsonOrThrow<ChunkUploadResponse>(response);
}
export async function finalizeUploadSession(
projectId: string,
sessionId: string,
totalChunks: number,
completeUpload: boolean,
fingerprint?: string | null,
lasFree = false,
confirmReplace = false,
): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
las_free: lasFree,
confirm_replace: confirmReplace,
}),
});
return await readJsonOrThrow<FileUploadResponse>(response);
}
export async function fetchUploadStatus(
projectId: string,
sessionId: string,
): Promise<UploadStatusResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
method: "GET",
credentials: "include",
});
return await readJsonOrThrow<UploadStatusResponse>(response);
}
export interface UploadOverviewFile {
input_file_id: number;
file_type: string;
original_filename: string;
file_size_mb: number;
status: string;
uploaded_at: string | null;
/** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */
relative_path: string | null;
/** 업로드 분석 결과 — 카드 미리보기(범위 사각형·노선 선)의 재료. */
metadata?: Record<string, unknown> | null;
}
export interface UploadOverviewSession {
upload_session_id: string;
original_filename: string;
file_size_bytes: number;
total_chunks: number;
completed_chunks: number;
progress_percent: number;
updated_at: string | null;
}
/** 지금 분석이 도는 중이면 **누가 시작했는지**. 도는 중이 아니면 `null`. */
export interface UploadLockInfo {
running: boolean;
user_id: number | null;
name: string | null;
email: string | null;
started_at: string | null;
}
export interface UploadOverviewResponse {
status: string;
files: UploadOverviewFile[];
pending_sessions: UploadOverviewSession[];
required_complete: boolean;
analysis_complete: boolean;
/** 서버가 새 자료를 막는 동안 **왜·누구 때문에** 막혔는지(2026-09-09, 계획서 0-8). */
analysis_lock?: UploadLockInfo | null;
}
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
method: "GET",
credentials: "include",
});
return await readJsonOrThrow<UploadOverviewResponse>(response);
}
export interface WF1AnalysisStatus {
project_id: string;
status: "pending" | "in_progress" | "completed" | "failed";
model_count: number;
progress_percent: number;
current_stage: string;
message: string;
error?: string;
}
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
method: "GET",
credentials: "include",
});
return await readJsonOrThrow<WF1AnalysisStatus>(response);
}