diff --git a/app/modules/expense/router.py b/app/modules/expense/router.py index fd70b6b..9c894bb 100644 --- a/app/modules/expense/router.py +++ b/app/modules/expense/router.py @@ -51,6 +51,41 @@ ALLOWED_EXTS = { } MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB +# 이미지 자동 리사이즈 정책: 가로 > RESIZE_MAX_W 면 비율 유지 축소 후 JPEG q80 재인코딩. +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"} +RESIZE_MAX_W = 1000 +JPEG_QUALITY = 80 + + +def _maybe_downscale_image( + data: bytes, raw_name: str, ext: str +) -> tuple[bytes, str, str, str | None]: + """가로 1000px 초과 이미지를 1000px(비율 유지)로 축소 + JPEG q80 재인코딩. + + 반환: (바이트, 파일명, 확장자, content_type|None). + 이미지 아님/축소 불필요/처리 실패 시 원본 그대로(content_type=None). + """ + if ext not in IMAGE_EXTS: + return data, raw_name, ext, None + try: + from PIL import Image, ImageOps # 지연 import + + im = Image.open(io.BytesIO(data)) + im = ImageOps.exif_transpose(im) # EXIF 회전 보정 + w, h = im.size + if w <= RESIZE_MAX_W: + return data, raw_name, ext, None # 작은 이미지는 원본 유지 + new_h = max(1, round(h * RESIZE_MAX_W / w)) + im = im.resize((RESIZE_MAX_W, new_h), Image.LANCZOS) + if im.mode not in ("RGB", "L"): + im = im.convert("RGB") + out = io.BytesIO() + im.save(out, format="JPEG", quality=JPEG_QUALITY, optimize=True) + stem = os.path.splitext(raw_name)[0] or "image" + return out.getvalue(), f"{stem}.jpg", ".jpg", "image/jpeg" + except Exception: + return data, raw_name, ext, None # 처리 실패 시 원본 저장 + def _store(request: Request) -> Any: store = getattr(request.app.state, "expense_store", None) @@ -897,38 +932,42 @@ async def upload_attachment( detail=f"허용되지 않는 확장자: {ext}", ) - # 디스크에 저장 (스트리밍, 사이즈 제한) + # 메모리로 읽기 (사이즈 제한) + data = bytearray() + chunk_size = 1024 * 256 + try: + while True: + chunk = await file.read(chunk_size) + if not chunk: + break + data.extend(chunk) + if len(data) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"파일 크기 초과 (최대 {MAX_UPLOAD_BYTES // (1024*1024)}MB)", + ) + finally: + await file.close() + + # 이미지면 가로 1000px 초과 시 비율 유지 축소 + JPEG q80 + upload_ct = file.content_type + data_bytes, raw_name, ext, forced_ct = _maybe_downscale_image( + bytes(data), raw_name, ext + ) + content_type = ( + forced_ct + or upload_ct + or mimetypes.guess_type(raw_name)[0] + or "application/octet-stream" + ) + + # 디스크에 저장 att_id = uuid.uuid4().hex[:12] sub_dir = _upload_dir(request) / item_id sub_dir.mkdir(parents=True, exist_ok=True) stored_name = f"{att_id}_{raw_name}" stored_path = sub_dir / stored_name - - size_total = 0 - chunk_size = 1024 * 256 - try: - with stored_path.open("wb") as out: - while True: - chunk = await file.read(chunk_size) - if not chunk: - break - size_total += len(chunk) - if size_total > MAX_UPLOAD_BYTES: - out.close() - stored_path.unlink(missing_ok=True) - raise HTTPException( - status_code=413, - detail=f"파일 크기 초과 (최대 {MAX_UPLOAD_BYTES // (1024*1024)}MB)", - ) - out.write(chunk) - finally: - await file.close() - - content_type = ( - file.content_type - or mimetypes.guess_type(raw_name)[0] - or "application/octet-stream" - ) + stored_path.write_bytes(data_bytes) rec = store.add_attachment( item_id=item_id, @@ -937,7 +976,7 @@ async def upload_attachment( filename=raw_name, stored_path=str(stored_path), content_type=content_type, - size_bytes=size_total, + size_bytes=len(data_bytes), ) return JSONResponse({"attachment": rec}, status_code=201) diff --git a/requirements.txt b/requirements.txt index d569596..58a4a26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ python-dotenv>=1.0 psycopg[binary,pool]>=3.2 python-multipart>=0.0.20 openpyxl>=3.1 +pillow>=10.0