feat(expense): 영수증 이미지 업로드 시 가로 1000px 초과면 축소 + JPEG q80

- 가로 > 1000px 이미지: 비율 유지 1000px 축소 후 JPEG 품질 80 재인코딩
- EXIF 회전 보정, 알파/팔레트 → RGB 변환, 확장자 .jpg 로 통일
- 1000px 이하/비이미지/처리 실패 시 원본 그대로 저장
- 업로드를 메모리 읽기 후 처리 방식으로 변경(20MB 제한 유지)
- requirements: pillow>=10.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 18:12:09 +09:00
parent 5582c72467
commit e782cb4ea7
2 changed files with 68 additions and 28 deletions
+67 -28
View File
@@ -51,6 +51,41 @@ ALLOWED_EXTS = {
} }
MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB 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: def _store(request: Request) -> Any:
store = getattr(request.app.state, "expense_store", None) store = getattr(request.app.state, "expense_store", None)
@@ -897,38 +932,42 @@ async def upload_attachment(
detail=f"허용되지 않는 확장자: {ext}", 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] att_id = uuid.uuid4().hex[:12]
sub_dir = _upload_dir(request) / item_id sub_dir = _upload_dir(request) / item_id
sub_dir.mkdir(parents=True, exist_ok=True) sub_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{att_id}_{raw_name}" stored_name = f"{att_id}_{raw_name}"
stored_path = sub_dir / stored_name stored_path = sub_dir / stored_name
stored_path.write_bytes(data_bytes)
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"
)
rec = store.add_attachment( rec = store.add_attachment(
item_id=item_id, item_id=item_id,
@@ -937,7 +976,7 @@ async def upload_attachment(
filename=raw_name, filename=raw_name,
stored_path=str(stored_path), stored_path=str(stored_path),
content_type=content_type, content_type=content_type,
size_bytes=size_total, size_bytes=len(data_bytes),
) )
return JSONResponse({"attachment": rec}, status_code=201) return JSONResponse({"attachment": rec}, status_code=201)
+1
View File
@@ -8,3 +8,4 @@ python-dotenv>=1.0
psycopg[binary,pool]>=3.2 psycopg[binary,pool]>=3.2
python-multipart>=0.0.20 python-multipart>=0.0.20
openpyxl>=3.1 openpyxl>=3.1
pillow>=10.0