feat(upload): §4-독립 — error_code 체계 + .uploading orphan cleanup + 진행률/abort UX

plan: ~/.claude/plans/luminous-sprouting-hamster.md §4 (1GB/stt/dashboard 외 독립 항목)

backend:
- _upload_error(status, code, msg) 헬퍼 정의 (§3 가 호출만 추가했던 누락 수정).
  detail = {error_code, message} — 프론트가 error_code 로 분기.
- upload_document 의 모든 HTTPException 을 _upload_error 로 전환:
  body_too_large / invalid_input / empty_file / unsupported_codec / internal
- ClientDisconnect → 499 network_abort + 임시파일 정리.
  asyncio.TimeoutError → 408 upload_timeout.
- 쓰기 중 .uploading 임시명 → 완료 후 staging.replace(target) atomic rename.
  → 프로세스 크래시 잔존물은 cleanup_orphan_uploads 가 수거.
- file_watcher SKIP_EXTENSIONS 에 .uploading 추가 (오해 픽업 방지).

cleanup scheduler:
- workers/upload_cleanup.py 신규. 10분 주기로 Inbox 하위 *.uploading 중
  mtime > orphan_max_age_sec(3600) 인 파일 삭제.
- 최근 3회 (≈30분) 누적 삭제 수가 cleanup_warn_threshold(10) 이상이면
  WARNING 로그. in-memory deque (재시작 시 리셋) — 집요한 이슈만 잡는 목적.
- core/config.py UploadConfig 에 두 임계치 필드 (defaults — config.yaml override 무관).

frontend:
- api.ts: ApiError 에 optional errorCode/errorMessage 필드 (detail string 유지로
  기존 5+ 소비자 호환). parseDetail() 가 {error_code, message} 객체 응답을 풀어
  정규화. uploadFile(path, formData, {signal, onProgress}) XHR 헬퍼 신규
  (fetch() 가 upload progress 미지원이라 XHR). 401 refresh 1회 정책 동일.
- UploadDropzone.svelte 재작성: 진행률 바, 파일별/전체 abort 버튼, 페이지 이탈
  beforeunload 경고, errorCode 별 토스트 메시지 분기 (7 코드 — body_too_large /
  upload_timeout / network_abort / empty_file / invalid_input / unsupported_codec /
  internal). 컴포넌트 unmount 시 진행 중 업로드 abort.

보류:
- max_bytes 1GB 상향 + Caddyfile 1100MB (별도 결정으로 100MB 유지)
- /dashboard 카테고리 카드 (별도 plan)
- docs/categories.md (§1-3 정의 안착 후)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-24 06:57:02 +09:00
parent 1e2c004dd4
commit 8f25d396df
7 changed files with 458 additions and 68 deletions
+66 -23
View File
@@ -25,6 +25,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.requests import ClientDisconnect
from ai.client import AIClient, _load_prompt, parse_json_response
from core.auth import get_current_user
@@ -39,6 +40,25 @@ from services.prompt_versions import ANALYZE_PROMPT_VERSION, resolve_primary_mod
from services.search.llm_gate import get_mlx_gate
router = APIRouter()
logger = logging.getLogger(__name__)
def _upload_error(status_code: int, error_code: str, message: str) -> HTTPException:
"""업로드 실패 응답. detail 은 객체 — 프론트가 error_code 로 분기.
error_code 종류:
body_too_large — Content-Length 또는 스트리밍 누적이 max_bytes 초과 (413)
upload_timeout — 서버 read timeout (408)
network_abort — 클라이언트 abort / 연결 끊김 (499)
empty_file — 0바이트 (400)
invalid_input — 파일명/경로/필드 검증 실패 (400)
unsupported_codec — 웹 업로드에서 direct-play 불가 비디오 (400, §3 video)
internal — 그 외 알 수 없는 에러 (500)
"""
return HTTPException(
status_code=status_code,
detail={"error_code": error_code, "message": message},
)
# ─── 스키마 ───
@@ -541,11 +561,14 @@ async def upload_document(
"""파일 업로드 → Inbox 저장 + DB 등록 + 처리 큐 등록.
Size 한도: `settings.upload.max_bytes` (authoritative).
- Content-Length 사전 차단 (slack_ratio 여유) → 413
- 스트리밍 누적 검사 (Content-Length 위조 방어) → 413
- 0바이트 파일은 400 reject
- Content-Length 사전 차단 (slack_ratio 여유) → 413 body_too_large
- 스트리밍 누적 검사 (Content-Length 위조 방어) → 413 body_too_large
- 0바이트 파일은 400 empty_file reject
- 쓰기 중에는 `<name>.uploading` 임시명 → 완료 후 atomic rename.
→ 프로세스 크래시 시 잔존물은 cleanup_orphan_uploads 스케줄러가 수거.
- 클라이언트 abort (`ClientDisconnect`) → 499 network_abort + 임시파일 정리
- 파일 저장 완료 후에만 DB 레코드 생성 (고아 레코드 방지)
- 예외 발생 시 partial file cleanup
- 에러 응답 detail 은 `{error_code, message}` 객체 — 프론트가 코드별 분기.
"""
from core.library import DEFAULT_LIBRARY_PATH, LIBRARY_PREFIX, normalize_library_path
@@ -561,7 +584,7 @@ async def upload_document(
try:
cl = int(content_length_header)
if cl > int(max_bytes * slack_ratio):
raise HTTPException(status_code=413, detail="파일이 너무 큽니다")
raise _upload_error(413, "body_too_large", "파일이 너무 큽니다")
except ValueError:
pass # 잘못된 헤더는 스트리밍 단계에서 max_bytes 로 차단
@@ -571,7 +594,7 @@ async def upload_document(
if doc_purpose == "":
doc_purpose = None
elif doc_purpose not in ("business", "knowledge"):
raise HTTPException(status_code=400, detail="doc_purpose는 business 또는 knowledge만 가능")
raise _upload_error(400, "invalid_input", "doc_purpose는 business 또는 knowledge만 가능")
# library_path 검증 + 정규화
library_tag = None
@@ -580,19 +603,19 @@ async def upload_document(
normalized = normalize_library_path(library_path)
library_tag = f"{LIBRARY_PREFIX}{normalized}"
except ValueError as e:
raise HTTPException(status_code=400, detail=f"잘못된 자료실 경로: {e}")
raise _upload_error(400, "invalid_input", f"잘못된 자료실 경로: {e}")
# 자료실 업로드인데 경로 미지정 → 미분류 자동 태깅
if doc_purpose == "business" and not library_tag:
library_tag = f"{LIBRARY_PREFIX}{DEFAULT_LIBRARY_PATH}"
if not file.filename:
raise HTTPException(status_code=400, detail="파일명이 필요합니다")
raise _upload_error(400, "invalid_input", "파일명이 필요합니다")
# 파일명 정규화 (경로 이탈 방지)
safe_name = Path(file.filename).name
if not safe_name or safe_name.startswith("."):
raise HTTPException(status_code=400, detail="유효하지 않은 파일명")
raise _upload_error(400, "invalid_input", "유효하지 않은 파일명")
# §3: 웹 업로드는 direct-play 불가 비디오 거부 (NAS 드롭은 file_watcher 가
# quarantine 으로 수용). UploadDropzone 이 error_code='unsupported_codec' 로
@@ -613,32 +636,55 @@ async def upload_document(
# Inbox 하위 경로 검증
if not str(target).startswith(str(inbox_dir.resolve())):
raise HTTPException(status_code=400, detail="잘못된 파일 경로")
raise _upload_error(400, "invalid_input", "잘못된 파일 경로")
# 중복 파일명 처리
# 중복 파일명 처리 — 최종 target 도, 임시 .uploading 파일도 모두 충돌 회피
counter = 1
stem, suffix = target.stem, target.suffix
while target.exists():
staging = target.with_name(target.name + ".uploading")
while target.exists() or staging.exists():
target = inbox_dir.resolve() / f"{stem}_{counter}{suffix}"
staging = target.with_name(target.name + ".uploading")
counter += 1
# ── 스트리밍 저장 + 누적 size 검사 ──
# ── 스트리밍 저장 + 누적 size 검사 (`.uploading` 임시명) ──
written = 0
try:
with target.open("wb") as f:
with staging.open("wb") as f:
while chunk := await file.read(chunk_size):
written += len(chunk)
if written > max_bytes:
raise HTTPException(status_code=413, detail="파일이 너무 큽니다")
raise _upload_error(413, "body_too_large", "파일이 너무 큽니다")
f.write(chunk)
# with 블록 종료 시 자동 flush + close
if written == 0:
raise HTTPException(status_code=400, detail="빈 파일은 업로드할 수 없습니다")
except Exception:
# partial file cleanup. KeyboardInterrupt/SystemExit 등 BaseException 계열은 잡지 않음.
target.unlink(missing_ok=True)
raise _upload_error(400, "empty_file", "빈 파일은 업로드할 수 없습니다")
except ClientDisconnect:
staging.unlink(missing_ok=True)
logger.info("upload aborted by client: %s (written=%d)", safe_name, written)
# 499 = nginx 관용 (Client Closed Request). 응답 도달 가능성 낮지만 일관 형식 유지.
raise _upload_error(499, "network_abort", "업로드가 취소되었습니다")
except asyncio.TimeoutError:
staging.unlink(missing_ok=True)
logger.warning("upload timeout: %s (written=%d)", safe_name, written)
raise _upload_error(408, "upload_timeout", "업로드 시간 초과")
except HTTPException:
# _upload_error 가 만든 예외는 그대로 통과 + 임시 파일 정리
staging.unlink(missing_ok=True)
raise
except Exception:
staging.unlink(missing_ok=True)
logger.exception("upload internal error: %s (written=%d)", safe_name, written)
raise _upload_error(500, "internal", "업로드 처리 중 오류가 발생했습니다")
# ── 파일 저장 완료: atomic rename → 최종 경로 ──
try:
staging.replace(target)
except OSError:
staging.unlink(missing_ok=True)
logger.exception("upload rename failed: %s -> %s", staging, target)
raise _upload_error(500, "internal", "파일 저장 후 정리 중 오류가 발생했습니다")
# ── 파일 저장 완료 후에만 hash + DB 레코드 ──
@@ -909,9 +955,6 @@ async def delete_document(
return {"message": f"문서 {doc_id} soft-delete 완료"}
logger = logging.getLogger(__name__)
@router.get("/{doc_id}/content")
async def get_document_content(
doc_id: int,
+3
View File
@@ -11,6 +11,9 @@ class UploadConfig(BaseModel):
max_bytes: int = 100_000_000
content_length_slack_ratio: float = 1.05
stream_chunk_bytes: int = 1_048_576
# orphan cleanup (`*.uploading` — 크래시/abort 후 잔존물)
orphan_max_age_sec: int = 3600
cleanup_warn_threshold: int = 10
class AIModelConfig(BaseModel):
+2
View File
@@ -38,6 +38,7 @@ async def lifespan(app: FastAPI):
from workers.mailplus_archive import run as mailplus_run
from workers.news_collector import run as news_collector_run
from workers.queue_consumer import consume_queue
from workers.upload_cleanup import cleanup_orphan_uploads
# 시작: DB 연결 확인
await init_db()
@@ -56,6 +57,7 @@ async def lifespan(app: FastAPI):
# 상시 실행
scheduler.add_job(consume_queue, "interval", minutes=1, id="queue_consumer")
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
scheduler.add_job(cleanup_orphan_uploads, "interval", minutes=10, id="upload_cleanup")
# 일일 스케줄 (KST)
scheduler.add_job(law_monitor_run, CronTrigger(hour=7), id="law_monitor")
scheduler.add_job(mailplus_run, CronTrigger(hour=7), id="mailplus_morning")
+1 -1
View File
@@ -24,7 +24,7 @@ logger = setup_logger("file_watcher")
# 무시할 파일
SKIP_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini", "Icon\r"}
SKIP_EXTENSIONS = {".tmp", ".part", ".crdownload"}
SKIP_EXTENSIONS = {".tmp", ".part", ".crdownload", ".uploading"}
# §3 확장자 매핑
AUDIO_EXTS = {".mp3", ".m4a", ".opus", ".wav", ".flac", ".ogg"}
+78
View File
@@ -0,0 +1,78 @@
"""업로드 임시파일 cleanup 워커.
업로드 엔드포인트는 `<name>.uploading` 임시명으로 NAS Inbox 에 쓴 뒤
완료 시 atomic rename 한다. 정상 abort 는 endpoint 의 except 절이 정리하지만
프로세스 크래시 / 강제 종료 / 비정상 종료 시 `*.uploading` 잔존물이 남는다.
이 워커는 10분 주기로 Inbox 하위를 스캔해서
- mtime 이 `orphan_max_age_sec` (기본 1시간) 보다 오래된 `*.uploading` 삭제
- 최근 3회 (≈30분) 누적 삭제 수가 `cleanup_warn_threshold` (기본 10) 이상이면 WARNING
카운터는 in-memory deque (프로세스 재시작 시 리셋). 집요한 이슈만 잡는 것이 목적.
"""
from __future__ import annotations
import time
from collections import deque
from pathlib import Path
from core.config import settings
from core.utils import setup_logger
logger = setup_logger("upload_cleanup")
# 최근 3회 run 의 삭제 카운트. 30분 윈도우 (10분 주기 × 3).
_recent_deletes: deque[int] = deque(maxlen=3)
async def cleanup_orphan_uploads() -> int:
"""`*.uploading` orphan 파일을 수거. 삭제 수 반환.
호출은 APScheduler 가 10분 주기로 트리거.
"""
inbox_path = Path(settings.nas_mount_path) / "PKM" / "Inbox"
if not inbox_path.exists():
return 0
max_age = settings.upload.orphan_max_age_sec
threshold = settings.upload.cleanup_warn_threshold
now = time.time()
deleted = 0
total_bytes = 0
for f in inbox_path.rglob("*.uploading"):
try:
if not f.is_file():
continue
age = now - f.stat().st_mtime
if age < max_age:
continue
size = f.stat().st_size
f.unlink()
deleted += 1
total_bytes += size
logger.info("orphan upload deleted: %s (age=%ds, size=%d)", f.name, int(age), size)
except OSError as e:
# 다른 프로세스가 정리 중이거나 권한 문제 — 다음 주기에 재시도
logger.warning("orphan upload cleanup skipped %s: %s", f, e)
_recent_deletes.append(deleted)
window_total = sum(_recent_deletes)
if window_total >= threshold:
logger.warning(
"upload orphan cleanup high — window=%d (last %d runs), threshold=%d. "
"abort 가 구조적으로 많거나 대용량 업로드 실패 반복 의심.",
window_total,
len(_recent_deletes),
threshold,
)
elif deleted > 0:
logger.info(
"upload orphan cleanup: deleted=%d bytes=%d window_sum=%d",
deleted,
total_bytes,
window_total,
)
return deleted
+140 -4
View File
@@ -73,11 +73,36 @@ async function handleTokenRefresh(): Promise<string> {
}
}
/**
* 일반 에러는 `detail` 만 string. 업로드 엔드포인트는 `{error_code, message}`
* 객체를 보내므로 (§4), 응답 파서가 그 객체를 풀어 `errorCode` / `errorMessage`
* 필드로 분리하고 `detail` 은 사람이 읽을 메시지(string) 로 정규화한다.
* 덕분에 기존 `err.detail` 소비자(toast 등)는 그대로 동작.
*/
export type ApiError = {
status: number;
detail: string;
errorCode?: string;
errorMessage?: string;
};
function parseDetail(body: unknown, fallback: string): {
detail: string;
errorCode?: string;
errorMessage?: string;
} {
if (body && typeof body === 'object' && 'detail' in body) {
const d = (body as { detail: unknown }).detail;
if (typeof d === 'string' && d) return { detail: d };
if (d && typeof d === 'object') {
const obj = d as { error_code?: string; message?: string };
const message = obj.message || fallback;
return { detail: message, errorCode: obj.error_code, errorMessage: obj.message };
}
}
return { detail: fallback };
}
export async function api<T = unknown>(
path: string,
options: RequestInit = {},
@@ -113,8 +138,9 @@ export async function api<T = unknown>(
credentials: 'include',
});
if (!retryRes.ok) {
const err = await retryRes.json().catch(() => ({ detail: 'Unknown error' }));
throw { status: retryRes.status, detail: err.detail || retryRes.statusText } as ApiError;
const body = await retryRes.json().catch(() => null);
const parsed = parseDetail(body, retryRes.statusText);
throw { status: retryRes.status, ...parsed } as ApiError;
}
return retryRes.json();
} catch (e) {
@@ -124,8 +150,9 @@ export async function api<T = unknown>(
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw { status: res.status, detail: err.detail || res.statusText } as ApiError;
const body = await res.json().catch(() => null);
const parsed = parseDetail(body, res.statusText);
throw { status: res.status, ...parsed } as ApiError;
}
// 204 No Content
@@ -133,3 +160,112 @@ export async function api<T = unknown>(
return res.json();
}
/**
* 업로드 전용 헬퍼 — XMLHttpRequest 기반.
*
* 이유: fetch() 는 upload progress 이벤트를 표준에서 지원하지 않음.
* 진행률 표시 + 사용자 abort 가 필수인 업로드만 XHR 로 분리.
*
* - access token 자동 첨부
* - 401 → refresh 1회 재시도 (api() 와 같은 정책)
* - signal: AbortSignal — 사용자 취소시 xhr.abort() 호출
* - onProgress(loaded, total): 전송 중 콜백
* - 에러는 ApiError 형식으로 reject
*/
export interface UploadOptions {
signal?: AbortSignal;
onProgress?: (loaded: number, total: number) => void;
}
export async function uploadFile<T = unknown>(
path: string,
formData: FormData,
opts: UploadOptions = {},
): Promise<T> {
const tryOnce = (token: string | null) =>
new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}${path}`);
xhr.withCredentials = true;
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
const abortHandler = () => xhr.abort();
if (opts.signal) {
if (opts.signal.aborted) {
xhr.abort();
} else {
opts.signal.addEventListener('abort', abortHandler);
}
}
const cleanup = () => {
if (opts.signal) opts.signal.removeEventListener('abort', abortHandler);
};
if (opts.onProgress) {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) opts.onProgress!(e.loaded, e.total);
});
}
xhr.addEventListener('load', () => {
cleanup();
if (xhr.status >= 200 && xhr.status < 300) {
if (!xhr.responseText) return resolve({} as T);
try {
resolve(JSON.parse(xhr.responseText));
} catch {
resolve({} as T);
}
} else {
let body: unknown = null;
try {
body = JSON.parse(xhr.responseText);
} catch {
// 응답 본문 파싱 실패 — fallback 사용
}
const parsed = parseDetail(body, xhr.statusText || '업로드 실패');
reject({ status: xhr.status, ...parsed } as ApiError);
}
});
xhr.addEventListener('error', () => {
cleanup();
reject({
status: 0,
detail: '네트워크 오류',
errorCode: 'network_abort',
errorMessage: '네트워크 오류',
} as ApiError);
});
xhr.addEventListener('abort', () => {
cleanup();
reject({
status: 0,
detail: '업로드가 취소되었습니다',
errorCode: 'network_abort',
errorMessage: '업로드가 취소되었습니다',
} as ApiError);
});
xhr.send(formData);
});
try {
return await tryOnce(accessToken);
} catch (e) {
const err = e as ApiError;
// 401 → refresh 1회. abort/네트워크 에러는 retry 안 함.
if (err.status === 401 && accessToken) {
try {
await handleTokenRefresh();
return await tryOnce(accessToken);
} catch (e2) {
if ((e2 as ApiError).detail) throw e2;
throw { status: 401, detail: '인증이 만료되었습니다' } as ApiError;
}
}
throw err;
}
}
+168 -40
View File
@@ -1,23 +1,71 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { onDestroy, onMount } from 'svelte';
import { uploadFile } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { publicConfig } from '$lib/stores/config';
import { Upload } from 'lucide-svelte';
import { Upload, X } from 'lucide-svelte';
let { onupload = () => {} } = $props();
// 업로드 크기 한도는 서버 `upload.max_bytes` 가 authoritative. 여기서는 pre-check UX 용으로만 사용.
// 실제 enforcement 는 /documents/ POST 413 응답 (서버 스트리밍 검증) 이 담당.
const maxBytes = $derived($publicConfig.upload.max_bytes);
const maxBytesLabel = $derived(`${Math.round($publicConfig.upload.max_bytes / 1_000_000)}MB`);
const NAS_FALLBACK_HINT = '대용량 파일은 NAS의 PKM/Inbox 폴더에 두면 자동 수집 대상이 됩니다. 감시 주기와 처리 대기열 상황에 따라 반영 시점은 달라질 수 있습니다.';
const maxBytesLabel = $derived(formatSize($publicConfig.upload.max_bytes));
const NAS_FALLBACK_HINT = '대용량 파일은 Synology Drive 의 PKM/Inbox 폴더에 두면 자동 수집 대상이 됩니다.';
let dragging = $state(false);
let uploading = $state(false);
// uploadFiles[i]: { name, size, status, loaded, total, controller, errorCode? }
let uploadFiles = $state([]);
let dragCounter = 0;
// 페이지 이탈 경고 (업로드 진행 중일 때만)
function onBeforeUnload(e) {
if (uploading && uploadFiles.some(f => f.status === 'uploading')) {
e.preventDefault();
e.returnValue = '';
return '';
}
}
function formatSize(bytes) {
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)}GB`;
if (bytes >= 1_000_000) return `${Math.round(bytes / 1_000_000)}MB`;
return `${Math.round(bytes / 1000)}KB`;
}
function errorMessage(err, fileName) {
// err: ApiError { status, detail (string), errorCode?, errorMessage? }
const code = err && err.errorCode;
if (code) {
switch (code) {
case 'body_too_large':
return `${fileName}: ${maxBytesLabel} 초과. ${NAS_FALLBACK_HINT}`;
case 'upload_timeout':
return `${fileName}: 업로드 시간 초과 — 네트워크 확인 후 재시도`;
case 'network_abort':
return `${fileName}: 업로드 취소됨`;
case 'empty_file':
return `${fileName}: 빈 파일은 업로드 불가`;
case 'invalid_input':
return `${fileName}: ${err.errorMessage || '입력값 오류'}`;
case 'unsupported_codec':
return `${fileName}: ${err.errorMessage || 'mp4(H.264)/webm 으로 변환 후 업로드'}`;
case 'internal':
return `${fileName}: 서버 오류 — 잠시 후 재시도`;
default:
return `${fileName}: ${err.errorMessage || code}`;
}
}
if (err && err.status === 413) {
return `${fileName}: 서버 거절 (Payload Too Large). ${NAS_FALLBACK_HINT}`;
}
if (err && err.status === 401) {
return `${fileName}: 인증 만료 — 다시 로그인하세요`;
}
return `${fileName}: ${(err && err.detail) || '업로드 실패'}`;
}
onMount(() => {
function onDragEnter(e) {
e.preventDefault();
@@ -25,12 +73,10 @@
dragCounter++;
dragging = true;
}
function onDragOver(e) {
e.preventDefault();
e.stopPropagation();
}
function onDragLeave(e) {
e.preventDefault();
e.stopPropagation();
@@ -40,7 +86,6 @@
dragCounter = 0;
}
}
function onDrop(e) {
e.preventDefault();
e.stopPropagation();
@@ -53,26 +98,48 @@
window.addEventListener('dragover', onDragOver);
window.addEventListener('dragleave', onDragLeave);
window.addEventListener('drop', onDrop);
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('dragenter', onDragEnter);
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('dragleave', onDragLeave);
window.removeEventListener('drop', onDrop);
window.removeEventListener('beforeunload', onBeforeUnload);
};
});
onDestroy(() => {
// 컴포넌트 언마운트 시 진행 중 업로드 abort
uploadFiles.forEach(f => {
if (f.status === 'uploading' && f.controller) f.controller.abort();
});
});
function abortFile(index) {
const f = uploadFiles[index];
if (f && f.status === 'uploading' && f.controller) {
f.controller.abort();
}
}
function abortAll() {
uploadFiles.forEach((f, i) => {
if (f.status === 'uploading') abortFile(i);
});
}
async function handleFiles(fileList) {
const allFiles = Array.from(fileList || []);
if (allFiles.length === 0) return;
// 사전 크기 검사 — 서버 한도(maxBytes) 초과는 즉시 차단 + NAS file_watcher 안내
// 사전 크기 검사 — 서버 한도 초과는 즉시 차단 + Drive 안내
const tooLarge = allFiles.filter(f => f.size > maxBytes);
const files = allFiles.filter(f => f.size <= maxBytes);
if (tooLarge.length > 0) {
const names = tooLarge
.map(f => `${f.name} (${(f.size / 1000 / 1000).toFixed(1)}MB)`)
.map(f => `${f.name} (${formatSize(f.size)})`)
.join(', ');
addToast(
'error',
@@ -84,46 +151,68 @@
if (files.length === 0) return;
uploading = true;
uploadFiles = files.map(f => ({ name: f.name, status: 'pending' }));
uploadFiles = files.map(f => ({
name: f.name,
size: f.size,
status: 'pending',
loaded: 0,
total: f.size,
controller: null,
}));
let success = 0;
let failed = 0;
let aborted = 0;
for (let i = 0; i < files.length; i++) {
uploadFiles[i].status = 'uploading';
const controller = new AbortController();
uploadFiles[i] = { ...uploadFiles[i], status: 'uploading', controller };
uploadFiles = [...uploadFiles];
try {
const formData = new FormData();
formData.append('file', files[i]);
await api('/documents/', { method: 'POST', body: formData });
uploadFiles[i].status = 'done';
await uploadFile('/documents/', formData, {
signal: controller.signal,
onProgress: (loaded, total) => {
uploadFiles[i] = { ...uploadFiles[i], loaded, total };
uploadFiles = [...uploadFiles];
},
});
uploadFiles[i] = { ...uploadFiles[i], status: 'done', controller: null };
uploadFiles = [...uploadFiles];
success++;
} catch (err) {
uploadFiles[i].status = 'failed';
failed++;
// 서버 측 413 (사전 검사 통과했지만 인프라 한도에 걸린 경우)
if (err && err.status === 413) {
addToast(
'error',
`${files[i].name}: 서버 거절 (Payload Too Large). ${NAS_FALLBACK_HINT}`,
10000
);
const code = err && err.errorCode ? err.errorCode : null;
const isAbort = code === 'network_abort';
uploadFiles[i] = {
...uploadFiles[i],
status: isAbort ? 'aborted' : 'failed',
controller: null,
errorCode: code,
};
uploadFiles = [...uploadFiles];
if (isAbort) {
aborted++;
} else {
failed++;
addToast('error', errorMessage(err, files[i].name), 10000);
}
}
uploadFiles = [...uploadFiles];
}
const skipped = tooLarge.length;
const tail =
(failed > 0 ? `, ${failed}건 실패` : '') +
(skipped > 0 ? `, ${skipped}용량 초과 스킵` : '');
(aborted > 0 ? `, ${aborted}취소` : '') +
(tooLarge.length > 0 ? `, ${tooLarge.length}건 용량 초과 스킵` : '');
if (success > 0) {
addToast('success', `${success}건 업로드 완료${tail}`);
onupload();
} else if (failed > 0) {
addToast('error', `업로드 실패 (${failed}건)${skipped > 0 ? `, ${skipped}건 용량 초과 스킵` : ''}`);
addToast('error', `업로드 실패 (${failed}건)${tail}`);
} else if (aborted > 0) {
addToast('info', `업로드 취소됨 (${aborted}건)${tail}`);
}
setTimeout(() => {
@@ -131,6 +220,11 @@
uploadFiles = [];
}, 3000);
}
function pct(f) {
if (!f.total) return 0;
return Math.min(100, Math.round((f.loaded / f.total) * 100));
}
</script>
<!-- 전체 페이지 드래그 오버레이 -->
@@ -139,6 +233,7 @@
<div class="bg-surface rounded-xl px-8 py-6 shadow-xl text-center">
<Upload size={32} class="mx-auto mb-2 text-accent" />
<p class="text-sm font-medium text-accent">여기에 파일을 놓으세요</p>
<p class="text-xs text-dim mt-1">최대 {maxBytesLabel}</p>
</div>
</div>
{/if}
@@ -146,19 +241,52 @@
<!-- 업로드 진행 상태 -->
{#if uploading && uploadFiles.length > 0}
<div class="mb-3 bg-surface border border-default rounded-lg p-3">
<p class="text-xs text-dim mb-2">업로드 중...</p>
<div class="space-y-1 max-h-32 overflow-y-auto">
{#each uploadFiles as f}
<div class="flex items-center justify-between text-xs">
<span class="truncate">{f.name}</span>
<span class={
f.status === 'done' ? 'text-success' :
f.status === 'failed' ? 'text-error' :
f.status === 'uploading' ? 'text-accent' :
'text-dim'
}>
{f.status === 'done' ? '✓' : f.status === 'failed' ? '✗' : f.status === 'uploading' ? '↑' : '…'}
</span>
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-dim">업로드 중...</p>
{#if uploadFiles.some(f => f.status === 'uploading')}
<button
type="button"
class="text-xs text-dim hover:text-error underline underline-offset-2"
onclick={abortAll}
>
전부 취소
</button>
{/if}
</div>
<div class="space-y-2 max-h-48 overflow-y-auto">
{#each uploadFiles as f, i}
<div class="text-xs">
<div class="flex items-center justify-between gap-2">
<span class="truncate flex-1">{f.name}</span>
<span class="text-dim shrink-0">{formatSize(f.size)}</span>
<span class={
f.status === 'done' ? 'text-success' :
f.status === 'failed' ? 'text-error' :
f.status === 'aborted' ? 'text-dim' :
f.status === 'uploading' ? 'text-accent' :
'text-dim'
}>
{f.status === 'done' ? '✓' :
f.status === 'failed' ? '✗' :
f.status === 'aborted' ? '⊘' :
f.status === 'uploading' ? `${pct(f)}%` : '…'}
</span>
{#if f.status === 'uploading'}
<button
type="button"
class="text-dim hover:text-error"
title="이 파일 업로드 취소"
onclick={() => abortFile(i)}
>
<X size={12} />
</button>
{/if}
</div>
{#if f.status === 'uploading'}
<div class="h-1 w-full bg-default/20 rounded mt-1 overflow-hidden">
<div class="h-full bg-accent transition-all" style="width: {pct(f)}%"></div>
</div>
{/if}
</div>
{/each}
</div>