[feat] negodata/backend: 상품 이미지 업로드(Azure Blob) 엔드포인트 추가

POST /v1/item/image — SAS 토큰을 URL에 붙여 httpx PUT 으로 Azure Blob 업로드.
이미지 타입/용량 검증 + IMAGE_* 에러코드. config 에 azure_blob_base_url/sas/root 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-18 16:21:58 +09:00
parent 49e682d693
commit 3280cab088
10 changed files with 287 additions and 15 deletions

View File

@ -53,6 +53,11 @@ class ErrorType(Enum):
# 협상카드 관련 에러
CARD_NOT_FOUND = 1700
# 이미지 업로드 관련 에러
IMAGE_INVALID_TYPE = 1800
IMAGE_TOO_LARGE = auto()
IMAGE_UPLOAD_FAILED = auto()
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
@ -110,25 +115,27 @@ class QuotationType(Enum):
class QuotationStatus(Enum):
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
CREATED = 1 # 견적생성
ACTIVE = 2 # 견적진행중
CLOSED = 3 # 견적마감
ON_HOLD = 4 # 협상보류
CREATED = 1
ACTIVE = 2
CLOSED = 3
ON_HOLD = 4
class SessionStatus(Enum):
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
NEGOTIATING = 1 # 협상중
COMPLETED = 2 # 협상종료
REJECTED = 3 # 협상거부
CREATED = 1
IN_PROGRESS = 2
DONE = 3
NOT_PARTICIPATED = 4
REJECTED = 5
class ChatSender(Enum):
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
BOT = 1 # 구매대행 봇
PARTNER = 2 # 협력사
BOT = 1
USER = 2
class DeliveryType(Enum):
@ -160,11 +167,13 @@ ENUM_LABELS = {
QuotationStatus.ACTIVE: "견적진행중",
QuotationStatus.CLOSED: "견적마감",
QuotationStatus.ON_HOLD: "협상보류",
SessionStatus.NEGOTIATING: "협상중",
SessionStatus.COMPLETED: "협상종료",
SessionStatus.CREATED: "협상생성",
SessionStatus.IN_PROGRESS: "협상중",
SessionStatus.DONE: "협상완료",
SessionStatus.NOT_PARTICIPATED: "미참여",
SessionStatus.REJECTED: "협상거부",
ChatSender.BOT: "봇",
ChatSender.PARTNER: "협력사",
ChatSender.USER: "협력사",
DeliveryType.PARTNER: "협력사배송",
DeliveryType.COURIER: "지정택배배송",
DeliveryType.PICKUP: "픽업배송",

View File

@ -36,3 +36,12 @@ access_key = "<JWT_ACCESS_SECRET>"
refresh_key = "<JWT_REFRESH_SECRET>"
access_expire_min = 30
refresh_expire_day = 7
# 상품 이미지 업로드 대상(Azure Blob Storage).
# infinith 와 동일 계정/컨테이너 SAS 를 그대로 복사해 쓰고, blob_root 로 디렉터리만 분리한다.
# 값 출처: o2o-infinith-backend/.env 의 AZURE_BLOB_BASE_URL / AZURE_BLOB_SAS_TOKEN
[StorageConfig]
azure_blob_base_url = "<AZURE_BLOB_BASE_URL>"
azure_blob_sas_token = "<AZURE_BLOB_SAS_TOKEN>"
blob_root = "negodata"
max_image_mb = 4

View File

@ -42,3 +42,13 @@ class JwtToken(ConfigModel):
refresh_key: str = ""
access_expire_min: int = 30
refresh_expire_day: int = 7
# 정적 파일(상품 이미지 등) 저장소 = Azure Blob Storage.
# infinith 와 같은 계정/컨테이너 SAS 를 그대로 쓰고, blob_root 로 디렉터리만 분리한다.
# SDK 없이 SAS 토큰을 URL 에 붙여 httpx 로 PUT 한다(services/azure_blob_client.py).
class StorageConfig(ConfigModel):
azure_blob_base_url: str = "" # https://<account>.blob.core.windows.net/<container>
azure_blob_sas_token: str = "" # SAS 토큰(쿼리스트링). 만료 있음 — 만료되면 업로드 실패
blob_root: str = "negodata" # 컨테이너 내 최상위 디렉터리(infinith 파일과 분리)
max_image_mb: int = 4 # 업로드 허용 최대 크기(MB). 프론트 ImageDropzone 와 일치

View File

@ -1,7 +1,7 @@
import os
from config.config_loader import Configs
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, StorageConfig
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local")
@ -19,6 +19,7 @@ web_server_config: WebServerConfig = configs.get(WebServerConfig)
log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig)
jwt_token_config: JwtToken = configs.get(JwtToken)
storage_config: StorageConfig = configs.get(StorageConfig)
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.

View File

@ -0,0 +1,148 @@
# 상품 이미지 업로드 설계 보고서
대상: `negodata/backend` (+ `negodata/front` 연동)
작성일: 2026-06-18
---
## 0. 결론
1. **별도 엔드포인트 `POST /v1/item/image` 를 신설한다.** create/update 와 합치지 않는다.
2. create/update 는 지금처럼 **JSON 바디** 그대로 두고 `image_url`(짧은 URL 문자열)만 받는다.
3. 저장은 **로컬디스크 + StaticFiles** 로 시작하고, 업로드 로직을 service 로 추상화해 추후 S3 로 교체한다.
4. DB 스키마(`items.image_url String(255)`) **변경 불필요** — 반환 URL이 짧은 경로이므로 그대로 들어간다.
---
## 1. 현재 상태 — 무엇이 되어 있고 무엇이 깨졌나
| 계층 | 현재 | 비고 |
|---|---|---|
| DB | `items.image_url = Column(String(255), nullable=True)` | URL **문자열 255자**만 수용 |
| protocol | `image_url: Optional[str]` (Create/Update/Data) | [protocol.py:19](../router/v1/item/protocol.py#L19) |
| front 컴포넌트 | `ImageDropzone` 가 파일 → `readAsDataURL` → **base64 data URL** 생성 | [ImageDropzone.tsx:50](../../front/src/components/ImageDropzone.tsx#L50) |
| front 제출 | `image_url: v.imageUrl \|\| 'https://...unsplash'` | [ProductFormSheet.tsx:154](../../front/src/features/products/components/ProductFormSheet.tsx#L154) |
| 저장 인프라 | StaticFiles mount / S3 / upload dir 설정 **전무** | [router.py:61](../router/router.py#L61) 라우터만 include |
**왜 "구현 안 됨" 인가:**
이미지를 실제로 드롭하면 수십 KB짜리 base64 문자열을 `String(255)` 칸에 insert → 길이 초과로 실패/잘림.
드롭하지 않으면 unsplash 플레이스홀더가 박힘. 즉 **UI(dropzone)는 있으나 바이너리를 받아 저장하고 짧은 URL을 돌려줄 백엔드 조각이 없다.**
현재 `UploadFile` 사용처는 엑셀 스텁 2개뿐 — [item.py:54](../router/v1/item/item.py#L54), [supplier.py:54](../router/v1/supplier/supplier.py#L54).
---
## 2. 핵심 결정 — 왜 별도 엔드포인트인가
| 기준 | create 와 합치기 (multipart 한 방) | **별도 엔드포인트 (권장)** |
|---|---|---|
| 요청 형식 | `multipart/form-data` 강제 → 모든 필드를 `Form()` 수동 파싱 | create/update 는 **JSON pydantic 유지** |
| 기존 패턴 | `Req_CreateItem.model_dump(exclude_unset=True)` 흐름 파괴 | 그대로 보존 |
| 생성 전 업로드 | 불가 (아이템이 있어야 첨부) | **신규 폼에서 먼저 업로드 → URL 확보** 가능 |
| 재사용 | create/update 마다 multipart 중복 | **업로드 1곳**, 협력사 로고 등 확장 |
| 생성 클라이언트(orval) | 혼합 바디라 타입 지저분 | 깔끔히 분리 생성 |
이 코드베이스는 이미 "JSON 바디"와 "multipart 파일"을 **엔드포인트 단위로 분리**해 둠(엑셀 업로드). 동일 원칙을 따른다.
---
## 3. 제안 API 계약
### 3.1 신규 엔드포인트
```
POST /v1/item/image
- auth: IsValidAccessToken (company_id 스코프)
- body: multipart/form-data, field "file": UploadFile
- 검증: content-type image/*, 용량 ≤ 4MB (front ImageDropzone 와 동일 한계)
- 동작: 저장 → 짧은 public URL 생성
- response_model: Res_ItemImage { image_url: str, ... }
```
create/update 는 **변경 없음** — front 가 위에서 받은 `image_url` 문자열을 기존 JSON 바디에 실어 보낸다.
### 3.2 protocol 추가 (주석 금지·auth 스타일 유지)
```python
# router/v1/item/protocol.py
class Res_ItemImage(Res_WebPacketProtocol):
image_url: Optional[str] = None
filename: Optional[str] = None
size: Optional[int] = None
```
### 3.3 router 스케치
```python
# router/v1/item/item.py
@router.post(path="/image", response_model=Res_ItemImage, summary="상품 이미지 업로드")
async def upload_item_image(
service: ItemService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
file: UploadFile = File(...),
):
return RemoveNoneResponse(await service.upload_image(user_info.company_id, file))
```
### 3.4 service 스케치 (저장 추상화 — 여기만 갈아끼우면 S3 전환)
```python
# services/item_service.py
async def upload_image(self, company_id: str, file: UploadFile) -> Res_ItemImage:
res = Res_ItemImage()
# 1) 검증: content_type.startswith("image/"), size ≤ 4MB → 실패 시 res.result.SetResult(...)
# 2) 저장: 키 = f"{company_id}/{uuid4()}.{ext}" ← 회사별 디렉터리로 격리
# storage.save(key, await file.read()) # 로컬: upload_dir/key, S3: put_object
# 3) res.image_url = f"{static_base_url}/items/{key}" ← String(255) 안에 들어가는 짧은 경로
return res
```
---
## 4. 저장 방식
| 방식 | 지금 채택 | 비고 |
|---|---|---|
| **A. 로컬디스크 + StaticFiles** | ✅ now | `app.mount("/static", StaticFiles(directory=upload_dir))` 한 줄. 단일 서버/데모에 충분 |
| B. S3 / MinIO / GCS | later | `boto3` 미설치. service 의 `storage.save` 만 교체하면 됨 (CDN URL 반환) |
> 멀티워커(`process_count`)·다중 인스턴스로 가면 로컬디스크는 인스턴스마다 갈라지므로 **그 시점에 B로 전환**해야 한다. 지금 단계에서는 A로 충분.
### 4.1 config 추가 제안
```python
# config/config_models.py — 신규 섹션
class StorageConfig(ConfigModel):
upload_dir: str = "./uploads" # 로컬 저장 루트
static_base_url: str = "" # 예: "http://localhost:8000/static"
max_image_mb: int = 4 # front ImageDropzone(4MB)와 일치
```
`client_url`(CORS, [config_models.py:10](../config/config_models.py#L10))과 동일하게 `config.local.toml` 에 값을 채운다. `.example` 에도 키 추가.
---
## 5. 변경 체크리스트
**backend**
- [ ] `config_models.py` 에 `StorageConfig` 추가 + `config.local.toml(.example)` 키 채움
- [ ] `router.py` 에 `app.mount("/static", StaticFiles(...))` (방식 A)
- [ ] `protocol.py` 에 `Res_ItemImage` 추가
- [ ] `item.py` 에 `POST /v1/item/image` 라우트
- [ ] `item_service.py` 에 `upload_image()` + storage 추상화(local 구현)
- [ ] 검증 실패용 `ErrorType` (예: `IMAGE_INVALID_TYPE`, `IMAGE_TOO_LARGE`) 추가
- [ ] 테스트: `tests/test_item.py` 에 업로드 정상/타입오류/용량초과
**front**
- [ ] orval 재생성 → `POST /v1/item/image` 클라이언트 확보
- [ ] `ImageDropzone` 가 base64 대신 **선택 파일을 업로드 호출 → 반환 `image_url`** 을 form `imageUrl` 에 세팅 (미리보기는 로컬 objectURL 유지 가능)
- [ ] `ProductFormSheet.tsx:154` 의 unsplash 플레이스홀더 폴백 제거/정리
---
## 6. 결정 필요 (열린 질문)
1. **저장 위치**: 로컬디스크(A)로 시작 확정? 아니면 처음부터 S3? — 권장: A.
2. **접근 제어**: `/static` 을 완전 public 으로 둘지, 서명 URL/인증 프록시로 막을지. 상품 이미지가 민감하지 않다면 public 로 충분.
3. **이미지 가공**: 업로드 시 리사이즈/webp 변환/썸네일 생성 할지(목록 성능). 1차는 원본 저장만 권장.
4. **고아 파일 정리**: 생성 전 업로드 후 폼 취소 시 남는 파일 — 1차는 방치, 추후 배치 정리.

View File

@ -9,3 +9,4 @@ orjson
pydantic>=2.0
python-multipart
openpyxl
httpx

View File

@ -1,6 +1,6 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, File, Query, UploadFile
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
@ -13,6 +13,7 @@ from .protocol import (
Res_DeleteItem,
Res_Item,
Res_ItemCategories,
Res_ItemImage,
Res_ItemList,
Res_LowestPriceResult,
Res_LowestPriceTrigger,
@ -50,6 +51,15 @@ async def check_item_codes(req: Req_CheckCodes, service: ItemService = Depends()
return RemoveNoneResponse(await service.check_codes(user_info.company_id, req.codes))
@router.post(path="/image", response_model=Res_ItemImage, summary="상품 이미지 업로드(Azure Blob)")
async def upload_item_image(
service: ItemService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
file: UploadFile = File(...),
):
return RemoveNoneResponse(await service.upload_image(user_info.company_id, file))
@router.get(path="/{item_id}", response_model=Res_Item, summary="상품 조회")
async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_item(user_info.company_id, str(item_id)))

View File

@ -107,6 +107,12 @@ class Res_DeleteItem(Res_WebPacketProtocol):
pass
class Res_ItemImage(Res_WebPacketProtocol):
image_url: Optional[str] = None
filename: Optional[str] = None
size: Optional[int] = None
class Res_LowestPriceTrigger(Res_WebPacketProtocol):
item_id: str = ""
status: str = ""

View File

@ -0,0 +1,50 @@
import uuid
import httpx
from config.server_configs import storage_config
# 허용 content-type -> 저장 확장자. 목록 밖이면 업로드 거부.
_EXT_BY_TYPE = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
# 업로드용 공유 클라이언트(연결 풀링).
_shared_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _shared_client
if _shared_client is None or _shared_client.is_closed:
_shared_client = httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
)
return _shared_client
def is_allowed_image(content_type: str) -> bool:
return content_type in _EXT_BY_TYPE
async def upload_image(company_id: str, content: bytes, content_type: str) -> str:
"""Azure Blob 에 이미지를 올리고 접근 URL 을 돌려준다.
blob 경로: <blob_root>/<company_id>/items/<uuid>.<ext>
— company_id 로 멀티테넌트 격리.
실패 시 예외를 던진다(호출측에서 IMAGE_UPLOAD_FAILED 처리). 저장값은 SAS 를 뗀 public URL."""
ext = _EXT_BY_TYPE.get(content_type, "bin")
blob_path = f"{storage_config.blob_root}/{company_id}/items/{uuid.uuid4().hex}.{ext}"
base = storage_config.azure_blob_base_url.rstrip("/")
sas = storage_config.azure_blob_sas_token.strip("?'\"")
public_url = f"{base}/{blob_path}"
upload_url = f"{public_url}?{sas}"
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
resp = await _get_client().put(upload_url, content=content, headers=headers)
if resp.status_code not in (200, 201):
raise RuntimeError(f"Azure Blob upload failed: status={resp.status_code} body={resp.text[:200]}")
return public_url

View File

@ -1,12 +1,14 @@
import uuid
from fastapi import Depends
from fastapi import Depends, UploadFile
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items
from common.enums import DBWRType, ErrorType
from common.logger import LOG
from common.models.gmodel import PageParams
from services.azure_blob_client import is_allowed_image, upload_image as blob_upload_image
from config.server_configs import storage_config
from crud.item_crud import IItemCRUD, ItemCRUD
from router.v1.item.protocol import (
ItemCategory,
@ -15,6 +17,7 @@ from router.v1.item.protocol import (
Res_DeleteItem,
Res_Item,
Res_ItemCategories,
Res_ItemImage,
Res_ItemList,
)
@ -162,3 +165,28 @@ class ItemService:
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
async def upload_image(self, company_id: str, file: UploadFile) -> Res_ItemImage:
"""상품 이미지를 Azure Blob 에 올리고 image_url 을 돌려준다.
DB 는 건드리지 않는다 — 프론트가 이 URL 을 create/update 의 image_url 로 실어 보낸다."""
res = Res_ItemImage()
content_type = file.content_type or ""
if not is_allowed_image(content_type):
res.result.SetResult(ErrorType.IMAGE_INVALID_TYPE)
return res
content = await file.read()
if len(content) > storage_config.max_image_mb * 1024 * 1024:
res.result.SetResult(ErrorType.IMAGE_TOO_LARGE)
return res
try:
res.image_url = await blob_upload_image(company_id, content, content_type)
except Exception as ex:
LOG.e_no_callstack(ex)
res.result.SetResult(ErrorType.IMAGE_UPLOAD_FAILED)
return res
res.filename = file.filename
res.size = len(content)
return res