feat(B01,B03): 프로젝트 생성 전 임시 보관함 (temp upload)
라이다 원본은 업로드에 오래 걸려 프로젝트 정보 확정 전에 미리 올릴 수 있어야 한다.
계정에 묶인 임시 보관함을 만들고, 나중에 만든 프로젝트로 자료를 옮겨 쓴다.
저장·DB
- storage/tmp/{user_id}/{batch_id}/ 아래에 프로젝트 저장소와 동일한 구조를 써서
청크 저장·병합 엔진(resolve_upload_destination/merge_upload_chunks)을 그대로 재사용
- 010_temp_upload.sql: temp_upload_batches / temp_upload_files 신설,
upload_sessions.project_id NULL 허용 + temp_batch_id 추가(FK명 조회 후 재생성)
- config: TEMP_UPLOAD_DIR_NAME / TEMP_UPLOAD_RETENTION_DAYS(30) /
TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS(6)
백엔드
- B03_FileInput_Router_Temp.py: 묶음 생성·목록·삭제, 일반/청크 업로드, finalize,
이어올리기 상태 조회, 프로젝트 연결(attach)
- attach: 파일 이동 후 input_files 등록, stage 0 완료, WF1·자동 설계 체인 트리거
- common_util_temp_cleanup.py: 완료 시각 기준 만료분 주기 삭제(서버 시작 시 1회 포함)
프론트엔드
- B01 대시보드 임시 보관함 섹션: 프로젝트 등록과 같은 폼 + 보관 목록.
진행률은 모달이 아니라 리스트 행에 표시, 새로고침 후 이어올리기 지원
- B03 업로드 컨테이너 내부 불러오기 버튼과 선택 모달.
완료된 묶음만 노출하고, 선택 후 업로드를 누르면 이동과 분석으로 이어짐
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,11 @@
|
||||
import os
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from config.config_system import PROJECT_STORAGE_STAGE_DIRS, STORAGE_BASE_DIR
|
||||
from config.config_system import (
|
||||
PROJECT_STORAGE_STAGE_DIRS,
|
||||
STORAGE_BASE_DIR,
|
||||
TEMP_UPLOAD_DIR_NAME,
|
||||
)
|
||||
|
||||
PROJECT_STORAGE_LAYOUT_V2 = (
|
||||
("B03_FileInput", "input"),
|
||||
@@ -44,6 +48,32 @@ def ensure_project_storage_layout(project_root: str) -> None:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def resolve_temp_batch_path(user_id: int, batch_id: str, *, create: bool = True) -> str:
|
||||
"""임시 보관함 묶음 폴더(`storage/tmp/{user_id}/{batch_id}`)를 돌려준다.
|
||||
|
||||
내부 구조는 프로젝트 저장소와 똑같이 `B03_FileInput/input/...`을 쓴다 — 그래야 청크
|
||||
저장·병합 엔진(`resolve_upload_destination`, `resolve_chunk_session_dir`)을 그대로
|
||||
재사용할 수 있고, 나중에 프로젝트로 옮길 때도 같은 상대 경로로 이어 붙기만 하면 된다.
|
||||
"""
|
||||
if not str(user_id).isdigit():
|
||||
raise ValueError("임시 보관함 사용자 식별자가 올바르지 않습니다.")
|
||||
if not batch_id or any(sep in batch_id for sep in ("/", "\\", "..")):
|
||||
raise ValueError("임시 보관함 묶음 식별자가 올바르지 않습니다.")
|
||||
|
||||
temp_root = os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
|
||||
path = os.path.abspath(os.path.join(temp_root, str(user_id), batch_id))
|
||||
if os.path.commonpath((temp_root, path)) != temp_root or path == temp_root:
|
||||
raise ValueError("임시 보관함 경로가 보관함 루트를 벗어났습니다.")
|
||||
if create:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def temp_upload_root() -> str:
|
||||
"""임시 보관함 루트(`storage/tmp`) 절대 경로."""
|
||||
return os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
|
||||
|
||||
|
||||
def resolve_stored_project_path(relative_path: str) -> str:
|
||||
"""DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다."""
|
||||
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""임시 보관함 만료 정리.
|
||||
|
||||
라이다 원본은 수십 GB라 방치하면 디스크를 금방 채운다. 파일이 다 올라온 시각 기준
|
||||
`TEMP_UPLOAD_RETENTION_DAYS`가 지난 묶음을 주기적으로 지운다(2026-08-08 사용자 지시).
|
||||
보관 기간·주기는 `config/config_system.py`에서 조정한다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository_Temp import (
|
||||
delete_temp_batch,
|
||||
list_expired_temp_batches,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_temp_batch_path, temp_upload_root
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import (
|
||||
TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS,
|
||||
TEMP_UPLOAD_RETENTION_DAYS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def cleanup_expired_temp_uploads() -> int:
|
||||
"""만료된 보관함 묶음을 폴더째 지우고 DB 행도 제거한다. 지운 개수를 돌려준다."""
|
||||
pool = get_db_pool()
|
||||
removed = 0
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
expired = await list_expired_temp_batches(connection)
|
||||
for batch in expired:
|
||||
batch_id = str(batch["id"])
|
||||
user_id = int(batch["user_id"])
|
||||
try:
|
||||
batch_root = resolve_temp_batch_path(user_id, batch_id, create=False)
|
||||
shutil.rmtree(batch_root, ignore_errors=True)
|
||||
except ValueError:
|
||||
logger.warning("보관함 경로 해석 실패(행만 삭제): batch_id=%s", batch_id)
|
||||
await delete_temp_batch(connection, batch_id=batch_id, user_id=user_id)
|
||||
removed += 1
|
||||
logger.info(
|
||||
"임시 보관함 만료 삭제: batch_id=%s name=%s expires_at=%s",
|
||||
batch_id,
|
||||
batch.get("name"),
|
||||
batch.get("expires_at"),
|
||||
)
|
||||
if removed:
|
||||
await connection.commit()
|
||||
_remove_empty_user_dirs()
|
||||
except Exception:
|
||||
logger.exception("임시 보관함 만료 정리 실패")
|
||||
return removed
|
||||
|
||||
|
||||
def _remove_empty_user_dirs() -> None:
|
||||
"""묶음이 모두 사라진 사용자 폴더는 함께 정리한다."""
|
||||
root = temp_upload_root()
|
||||
if not os.path.isdir(root):
|
||||
return
|
||||
for entry in os.listdir(root):
|
||||
path = os.path.join(root, entry)
|
||||
if os.path.isdir(path) and not os.listdir(path):
|
||||
try:
|
||||
os.rmdir(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def cleanup_expired_temp_uploads_loop() -> None:
|
||||
"""서버 시작 직후 한 번, 이후 설정된 주기마다 만료분을 정리한다."""
|
||||
interval_seconds = max(1, TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS) * 3600
|
||||
logger.info(
|
||||
"임시 보관함 정리 루프 시작: 보관 %d일, 주기 %d시간",
|
||||
TEMP_UPLOAD_RETENTION_DAYS,
|
||||
TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS,
|
||||
)
|
||||
while True:
|
||||
removed = await cleanup_expired_temp_uploads()
|
||||
if removed:
|
||||
logger.info("임시 보관함 정리 완료: %d건 삭제", removed)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
Reference in New Issue
Block a user