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:
@@ -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)
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ python-dotenv>=1.0
|
||||
psycopg[binary,pool]>=3.2
|
||||
python-multipart>=0.0.20
|
||||
openpyxl>=3.1
|
||||
pillow>=10.0
|
||||
|
||||
Reference in New Issue
Block a user