[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:
parent
49e682d693
commit
3280cab088
@ -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: "픽업배송",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 와 일치
|
||||
|
||||
@ -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 그대로.
|
||||
|
||||
148
negodata/backend/docs/image-upload-design.md
Normal file
148
negodata/backend/docs/image-upload-design.md
Normal 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차는 방치, 추후 배치 정리.
|
||||
@ -9,3 +9,4 @@ orjson
|
||||
pydantic>=2.0
|
||||
python-multipart
|
||||
openpyxl
|
||||
httpx
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
50
negodata/backend/services/azure_blob_client.py
Normal file
50
negodata/backend/services/azure_blob_client.py
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user