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

View File

@ -36,3 +36,12 @@ access_key = "<JWT_ACCESS_SECRET>"
refresh_key = "<JWT_REFRESH_SECRET>" refresh_key = "<JWT_REFRESH_SECRET>"
access_expire_min = 30 access_expire_min = 30
refresh_expire_day = 7 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 = "" refresh_key: str = ""
access_expire_min: int = 30 access_expire_min: int = 30
refresh_expire_day: int = 7 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 import os
from config.config_loader import Configs 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 로 변경. # 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local") 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) log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig) main_db_config: MainDBConfig = configs.get(MainDBConfig)
jwt_token_config: JwtToken = configs.get(JwtToken) jwt_token_config: JwtToken = configs.get(JwtToken)
storage_config: StorageConfig = configs.get(StorageConfig)
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로. # 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 pydantic>=2.0
python-multipart python-multipart
openpyxl openpyxl
httpx

View File

@ -1,6 +1,6 @@
from uuid import UUID 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 common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
@ -13,6 +13,7 @@ from .protocol import (
Res_DeleteItem, Res_DeleteItem,
Res_Item, Res_Item,
Res_ItemCategories, Res_ItemCategories,
Res_ItemImage,
Res_ItemList, Res_ItemList,
Res_LowestPriceResult, Res_LowestPriceResult,
Res_LowestPriceTrigger, 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)) 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="상품 조회") @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)): 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))) 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 pass
class Res_ItemImage(Res_WebPacketProtocol):
image_url: Optional[str] = None
filename: Optional[str] = None
size: Optional[int] = None
class Res_LowestPriceTrigger(Res_WebPacketProtocol): class Res_LowestPriceTrigger(Res_WebPacketProtocol):
item_id: str = "" item_id: str = ""
status: 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 import uuid
from fastapi import Depends from fastapi import Depends, UploadFile
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items from common.database.model.models import items
from common.enums import DBWRType, ErrorType from common.enums import DBWRType, ErrorType
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import PageParams 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 crud.item_crud import IItemCRUD, ItemCRUD
from router.v1.item.protocol import ( from router.v1.item.protocol import (
ItemCategory, ItemCategory,
@ -15,6 +17,7 @@ from router.v1.item.protocol import (
Res_DeleteItem, Res_DeleteItem,
Res_Item, Res_Item,
Res_ItemCategories, Res_ItemCategories,
Res_ItemImage,
Res_ItemList, Res_ItemList,
) )
@ -162,3 +165,28 @@ class ItemService:
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res 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