diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 21d115a..abfb8e9 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -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: "픽업배송", diff --git a/negodata/backend/config/config.local.toml.example b/negodata/backend/config/config.local.toml.example index bc968f5..6bc9c59 100644 --- a/negodata/backend/config/config.local.toml.example +++ b/negodata/backend/config/config.local.toml.example @@ -36,3 +36,12 @@ access_key = "" refresh_key = "" 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_sas_token = "" +blob_root = "negodata" +max_image_mb = 4 diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py index 688873c..3610dc5 100644 --- a/negodata/backend/config/config_models.py +++ b/negodata/backend/config/config_models.py @@ -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://.blob.core.windows.net/ + azure_blob_sas_token: str = "" # SAS 토큰(쿼리스트링). 만료 있음 — 만료되면 업로드 실패 + blob_root: str = "negodata" # 컨테이너 내 최상위 디렉터리(infinith 파일과 분리) + max_image_mb: int = 4 # 업로드 허용 최대 크기(MB). 프론트 ImageDropzone 와 일치 diff --git a/negodata/backend/config/server_configs.py b/negodata/backend/config/server_configs.py index 73201f3..f9ceaf1 100644 --- a/negodata/backend/config/server_configs.py +++ b/negodata/backend/config/server_configs.py @@ -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 그대로. diff --git a/negodata/backend/docs/image-upload-design.md b/negodata/backend/docs/image-upload-design.md new file mode 100644 index 0000000..9a5be88 --- /dev/null +++ b/negodata/backend/docs/image-upload-design.md @@ -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차는 방치, 추후 배치 정리. diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt index 13fcb66..bfe5743 100644 --- a/negodata/backend/requirements.txt +++ b/negodata/backend/requirements.txt @@ -9,3 +9,4 @@ orjson pydantic>=2.0 python-multipart openpyxl +httpx diff --git a/negodata/backend/router/v1/item/item.py b/negodata/backend/router/v1/item/item.py index 70b6a81..c190f2a 100644 --- a/negodata/backend/router/v1/item/item.py +++ b/negodata/backend/router/v1/item/item.py @@ -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))) diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index 504a8ca..dc973a9 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -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 = "" diff --git a/negodata/backend/services/azure_blob_client.py b/negodata/backend/services/azure_blob_client.py new file mode 100644 index 0000000..179a063 --- /dev/null +++ b/negodata/backend/services/azure_blob_client.py @@ -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 경로: //items/. + — 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 diff --git a/negodata/backend/services/item_service.py b/negodata/backend/services/item_service.py index b5409f0..6db17b0 100644 --- a/negodata/backend/services/item_service.py +++ b/negodata/backend/services/item_service.py @@ -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