feat: 다중 이미지 업로드 및 표시 기능 완전 구현
✨ 주요 기능 추가: - 다중 이미지 업로드 지원 (최대 5개) - 체크리스트 완료 시 자동 사라짐 기능 - 캘린더 상세 모달에서 다중 이미지 표시 - Todo 변환 시 기존 이미지 보존 및 새 이미지 추가 🔧 백엔드 수정: - Todo 모델: image_url → image_urls (JSON 배열) - API 엔드포인트: 다중 이미지 직렬화/역직렬화 - 새 엔드포인트: POST /todos/{id}/add-image (이미지 추가) - 데이터베이스 마이그레이션 스크립트 추가 🎨 프론트엔드 개선: - 대시보드: 실제 API 데이터 연동, 다중 이미지 표시 - 업로드 모달: 다중 파일 선택, 실시간 미리보기, 5개 제한 - 체크리스트: 완료 시 1.5초 후 자동 제거, 토스트 메시지 - 캘린더 모달: 2x2 그리드 이미지 표시, 클릭 확대 - Todo 변환: 기존 이미지 + 새 이미지 합치기 🐛 버그 수정: - currentPhoto 변수 오류 해결 - 이미지 표시 문제 (단일 → 다중 지원) - 완료 처리 로컬/백엔드 동기화 - 새로고침 시 완료 항목 재출현 문제
This commit is contained in:
@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir -e .
|
||||
|
||||
# 애플리케이션 코드 복사
|
||||
COPY src/ ./src/
|
||||
COPY migrations/ ./migrations/
|
||||
RUN mkdir -p ./migrations
|
||||
|
||||
# 환경 변수 설정
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
19
backend/migrations/001_add_multiple_images.sql
Normal file
19
backend/migrations/001_add_multiple_images.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 다중 이미지 지원을 위한 마이그레이션
|
||||
-- image_url 컬럼을 image_urls로 변경하고 기존 데이터 마이그레이션
|
||||
|
||||
-- 1. 새 컬럼 추가
|
||||
ALTER TABLE todos ADD COLUMN image_urls TEXT;
|
||||
|
||||
-- 2. 기존 image_url 데이터를 image_urls로 마이그레이션 (JSON 배열 형태)
|
||||
UPDATE todos
|
||||
SET image_urls = CASE
|
||||
WHEN image_url IS NOT NULL AND image_url != ''
|
||||
THEN '["' || image_url || '"]'
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
-- 3. 기존 image_url 컬럼 삭제
|
||||
ALTER TABLE todos DROP COLUMN image_url;
|
||||
|
||||
-- 4. 인덱스 추가 (선택사항 - 검색 성능 향상)
|
||||
-- CREATE INDEX idx_todos_has_images ON todos ((image_urls IS NOT NULL));
|
||||
@@ -44,6 +44,12 @@ async def create_todo(
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date format: {todo_data.due_date}")
|
||||
|
||||
# 이미지 URLs JSON 변환
|
||||
import json
|
||||
image_urls_json = None
|
||||
if todo_data.image_urls:
|
||||
image_urls_json = json.dumps(todo_data.image_urls)
|
||||
|
||||
# 새 Todo 생성
|
||||
new_todo = Todo(
|
||||
user_id=current_user.id,
|
||||
@@ -51,7 +57,7 @@ async def create_todo(
|
||||
description=todo_data.description,
|
||||
category=todo_data.category,
|
||||
due_date=parsed_due_date,
|
||||
image_url=todo_data.image_url,
|
||||
image_urls=image_urls_json,
|
||||
tags=todo_data.tags
|
||||
)
|
||||
|
||||
@@ -60,6 +66,14 @@ async def create_todo(
|
||||
await db.refresh(new_todo)
|
||||
|
||||
# 응답용 데이터 변환
|
||||
import json
|
||||
image_urls_list = None
|
||||
if new_todo.image_urls:
|
||||
try:
|
||||
image_urls_list = json.loads(new_todo.image_urls)
|
||||
except json.JSONDecodeError:
|
||||
image_urls_list = None
|
||||
|
||||
response_data = {
|
||||
"id": new_todo.id,
|
||||
"user_id": new_todo.user_id,
|
||||
@@ -71,7 +85,7 @@ async def create_todo(
|
||||
"updated_at": new_todo.updated_at,
|
||||
"due_date": new_todo.due_date.isoformat() if new_todo.due_date else None,
|
||||
"completed_at": new_todo.completed_at,
|
||||
"image_url": new_todo.image_url,
|
||||
"image_urls": image_urls_list,
|
||||
"tags": new_todo.tags
|
||||
}
|
||||
|
||||
@@ -113,22 +127,30 @@ async def get_todos(
|
||||
todos = result.scalars().all()
|
||||
|
||||
# 응답용 데이터 변환
|
||||
import json
|
||||
response_data = []
|
||||
for todo in todos:
|
||||
response_data.append({
|
||||
"id": todo.id,
|
||||
"user_id": todo.user_id,
|
||||
"title": todo.title,
|
||||
"description": todo.description,
|
||||
"category": todo.category,
|
||||
"status": todo.status,
|
||||
"created_at": todo.created_at,
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_url": todo.image_url,
|
||||
"tags": todo.tags
|
||||
})
|
||||
image_urls_list = None
|
||||
if todo.image_urls:
|
||||
try:
|
||||
image_urls_list = json.loads(todo.image_urls)
|
||||
except json.JSONDecodeError:
|
||||
image_urls_list = None
|
||||
|
||||
response_data.append({
|
||||
"id": todo.id,
|
||||
"user_id": todo.user_id,
|
||||
"title": todo.title,
|
||||
"description": todo.description,
|
||||
"category": todo.category,
|
||||
"status": todo.status,
|
||||
"created_at": todo.created_at,
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_urls": image_urls_list,
|
||||
"tags": todo.tags
|
||||
})
|
||||
|
||||
return response_data
|
||||
|
||||
@@ -162,6 +184,14 @@ async def get_todo(
|
||||
)
|
||||
|
||||
# 응답용 데이터 변환
|
||||
import json
|
||||
image_urls_list = None
|
||||
if todo.image_urls:
|
||||
try:
|
||||
image_urls_list = json.loads(todo.image_urls)
|
||||
except json.JSONDecodeError:
|
||||
image_urls_list = None
|
||||
|
||||
response_data = {
|
||||
"id": todo.id,
|
||||
"user_id": todo.user_id,
|
||||
@@ -173,7 +203,7 @@ async def get_todo(
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_url": todo.image_url,
|
||||
"image_urls": image_urls_list,
|
||||
"tags": todo.tags,
|
||||
}
|
||||
|
||||
@@ -213,6 +243,7 @@ async def update_todo(
|
||||
)
|
||||
|
||||
# 업데이트할 필드만 수정
|
||||
import json
|
||||
update_data = todo_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == 'due_date' and value:
|
||||
@@ -222,6 +253,9 @@ async def update_todo(
|
||||
setattr(todo, field, parsed_date)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date format: {value}")
|
||||
elif field == 'image_urls' and value is not None:
|
||||
# 이미지 URLs를 JSON으로 변환
|
||||
setattr(todo, field, json.dumps(value))
|
||||
else:
|
||||
setattr(todo, field, value)
|
||||
|
||||
@@ -235,6 +269,13 @@ async def update_todo(
|
||||
await db.refresh(todo)
|
||||
|
||||
# 응답용 데이터 변환
|
||||
image_urls_list = None
|
||||
if todo.image_urls:
|
||||
try:
|
||||
image_urls_list = json.loads(todo.image_urls)
|
||||
except json.JSONDecodeError:
|
||||
image_urls_list = None
|
||||
|
||||
response_data = {
|
||||
"id": todo.id,
|
||||
"user_id": todo.user_id,
|
||||
@@ -246,7 +287,7 @@ async def update_todo(
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_url": todo.image_url,
|
||||
"image_urls": image_urls_list,
|
||||
"tags": todo.tags,
|
||||
}
|
||||
|
||||
@@ -300,3 +341,95 @@ async def delete_todo(
|
||||
detail="Todo 삭제에 실패했습니다."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{todo_id}/add-image")
|
||||
async def add_image_to_todo(
|
||||
todo_id: UUID,
|
||||
image: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""기존 Todo에 이미지 추가 (최대 5개까지)"""
|
||||
try:
|
||||
# Todo 조회
|
||||
result = await db.execute(
|
||||
select(Todo).where(
|
||||
and_(Todo.id == todo_id, Todo.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
todo = result.scalar_one_or_none()
|
||||
|
||||
if not todo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 현재 이미지 개수 확인
|
||||
import json
|
||||
current_images = []
|
||||
if todo.image_urls:
|
||||
try:
|
||||
current_images = json.loads(todo.image_urls)
|
||||
except json.JSONDecodeError:
|
||||
current_images = []
|
||||
|
||||
# 최대 5개 제한 확인
|
||||
if len(current_images) >= 5:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="최대 5개의 이미지만 저장할 수 있습니다."
|
||||
)
|
||||
|
||||
# 파일 형식 확인
|
||||
if not image.content_type.startswith('image/'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미지 파일만 업로드 가능합니다."
|
||||
)
|
||||
|
||||
# 파일 크기 확인 (5MB 제한)
|
||||
contents = await image.read()
|
||||
if len(contents) > 5 * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="파일 크기는 5MB를 초과할 수 없습니다."
|
||||
)
|
||||
|
||||
# Base64로 변환하여 저장
|
||||
import base64
|
||||
base64_string = f"data:{image.content_type};base64,{base64.b64encode(contents).decode()}"
|
||||
|
||||
# 파일 저장
|
||||
from ...services.file_service import save_base64_image
|
||||
file_url = save_base64_image(base64_string)
|
||||
if not file_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 저장에 실패했습니다."
|
||||
)
|
||||
|
||||
# 기존 이미지 목록에 새 이미지 추가
|
||||
current_images.append(file_url)
|
||||
todo.image_urls = json.dumps(current_images)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(todo)
|
||||
|
||||
return {
|
||||
"message": "이미지가 추가되었습니다.",
|
||||
"url": file_url,
|
||||
"total_images": len(current_images),
|
||||
"max_images": 5
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"이미지 추가 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 추가에 실패했습니다."
|
||||
)
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class Todo(Base):
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 추가 정보
|
||||
image_url = Column(Text, nullable=True) # 첨부 이미지 URL (Base64 데이터 지원)
|
||||
image_urls = Column(Text, nullable=True) # 첨부 이미지 URLs (JSON 배열 형태, 최대 5개)
|
||||
tags = Column(String(500), nullable=True) # 태그 (쉼표로 구분)
|
||||
|
||||
# 관계
|
||||
|
||||
@@ -31,7 +31,7 @@ class TodoBase(BaseModel):
|
||||
class TodoCreate(TodoBase):
|
||||
"""Todo 생성"""
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
image_url: Optional[str] = Field(None, max_length=10000000) # Base64 이미지를 위해 크게 설정
|
||||
image_urls: Optional[List[str]] = Field(None, max_items=5, description="최대 5개의 이미지 URL")
|
||||
tags: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ class TodoUpdate(BaseModel):
|
||||
category: Optional[TodoCategoryEnum] = None
|
||||
status: Optional[TodoStatusEnum] = None
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
image_url: Optional[str] = Field(None, max_length=10000000) # Base64 이미지를 위해 크게 설정
|
||||
image_urls: Optional[List[str]] = Field(None, max_items=5, description="최대 5개의 이미지 URL")
|
||||
tags: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class TodoResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
completed_at: Optional[datetime] = None
|
||||
image_url: Optional[str] = None
|
||||
image_urls: Optional[List[str]] = None
|
||||
tags: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
|
||||
Reference in New Issue
Block a user