insta
Dohyun Lim 2026-01-28 19:23:46 +09:00
parent 32ae5530b6
commit c07a2f6dae
20 changed files with 995 additions and 61 deletions

View File

@ -76,6 +76,8 @@
| **Video** | `/video/status/{creatomate_render_id}` | GET | 영상 상태 조회 |
| **Video** | `/video/download/{task_id}` | GET | 영상 다운로드 |
| **Video** | `/videos/` | GET | 영상 목록 조회 |
| **Archive** | `/archive/videos/` | GET | 완료된 영상 목록 조회 (아카이브) |
| **Archive** | `/archive/videos/{task_id}` | DELETE | 아카이브 영상 삭제 (CASCADE) |
---
@ -261,6 +263,8 @@ async def list_lyrics(...):
| `/video/status/{...}` | **선택적** | 상태 조회 |
| `/video/download/{task_id}` | **선택적** | 다운로드 |
| `/videos/` | **선택적** | 목록 조회 |
| `/archive/videos/` | **필수** | 완료된 영상 목록 조회 (아카이브) |
| `/archive/videos/{task_id}` | **필수** | 아카이브 영상 삭제 + 소유권 검증 |
---
@ -494,6 +498,8 @@ async def get_lyric_detail(
- [ ] `GET /video/status/{...}` - 선택적 인증 + Project 통해 소유권 검증
- [ ] `GET /video/download/{task_id}` - 선택적 인증 + Project 통해 소유권 검증
- [ ] `GET /videos/` - 선택적 인증 + Project.user_uuid 기반 필터
- [ ] `GET /archive/videos/` - 필수 인증 + Project.user_uuid 기반 필터
- [ ] `DELETE /archive/videos/{task_id}` - 필수 인증 + 소유권 검증 + CASCADE 삭제
### 4.4 Phase 4: 크롤링 엔드포인트
@ -541,6 +547,7 @@ def custom_openapi():
"/lyric/generate",
"/song/generate",
"/video/generate",
"/archive/videos", # GET (목록조회), DELETE (삭제)
]
AUTH_OPTIONAL_PATHS = [

View File

@ -0,0 +1,334 @@
"""
Archive API 라우터
사용자의 아카이브(완료된 영상 목록) 관련 엔드포인트를 제공합니다.
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.archive.worker.archive_task import soft_delete_by_task_id
from app.database.session import get_session
from app.dependencies.pagination import PaginationParams, get_pagination_params
from app.home.models import Project
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse
from app.video.models import Video
from app.video.schemas.video_schema import VideoListItem
logger = get_logger(__name__)
router = APIRouter(prefix="/archive", tags=["Archive"])
@router.get(
"/videos/",
summary="완료된 영상 목록 조회",
description="""
## 개요
완료된(status='completed') 영상 목록을 페이지네이션하여 반환합니다.
## 쿼리 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
## 반환 정보
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
- **total**: 전체 데이터
- **page**: 현재 페이지
- **page_size**: 페이지당 데이터
- **total_pages**: 전체 페이지
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /archive/videos/?page=1&page_size=10
```
## 참고
- status가 'completed' 영상만 반환됩니다.
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
- created_at 기준 내림차순 정렬됩니다.
""",
response_model=PaginatedResponse[VideoListItem],
responses={
200: {"description": "영상 목록 조회 성공"},
500: {"description": "조회 실패"},
},
)
async def get_videos(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[VideoListItem]:
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
logger.info(
f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
)
logger.debug(f"[get_videos] current_user.user_uuid: {current_user.user_uuid}")
try:
offset = (pagination.page - 1) * pagination.page_size
# DEBUG: 각 조건별 데이터 수 확인
# 1) 전체 Video 수
all_videos_result = await session.execute(select(func.count(Video.id)))
all_videos_count = all_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 전체 Video 수: {all_videos_count}")
# 2) completed 상태 Video 수
completed_videos_result = await session.execute(
select(func.count(Video.id)).where(Video.status == "completed")
)
completed_videos_count = completed_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - completed 상태 Video 수: {completed_videos_count}")
# 3) is_deleted=False인 Video 수
not_deleted_videos_result = await session.execute(
select(func.count(Video.id)).where(Video.is_deleted == False)
)
not_deleted_videos_count = not_deleted_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - is_deleted=False Video 수: {not_deleted_videos_count}")
# 4) 전체 Project 수 및 user_uuid 값 확인
all_projects_result = await session.execute(
select(Project.id, Project.user_uuid, Project.is_deleted)
)
all_projects = all_projects_result.all()
logger.debug(f"[get_videos] DEBUG - 전체 Project 수: {len(all_projects)}")
for p in all_projects:
logger.debug(
f"[get_videos] DEBUG - Project: id={p.id}, user_uuid={p.user_uuid}, "
f"user_uuid_type={type(p.user_uuid)}, is_deleted={p.is_deleted}"
)
# 4-1) 현재 사용자 UUID 타입 확인
logger.debug(
f"[get_videos] DEBUG - current_user.user_uuid={current_user.user_uuid}, "
f"type={type(current_user.user_uuid)}"
)
# 4-2) 현재 사용자 소유 Project 수
user_projects_result = await session.execute(
select(func.count(Project.id)).where(
Project.user_uuid == current_user.user_uuid,
Project.is_deleted == False,
)
)
user_projects_count = user_projects_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 현재 사용자 소유 Project 수: {user_projects_count}")
# 5) 현재 사용자 소유 + completed + 미삭제 Video 수
user_completed_videos_result = await session.execute(
select(func.count(Video.id))
.join(Project, Video.project_id == Project.id)
.where(
Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
Video.is_deleted == False,
Project.is_deleted == False,
)
)
user_completed_videos_count = user_completed_videos_result.scalar() or 0
logger.debug(
f"[get_videos] DEBUG - 현재 사용자 소유 + completed + 미삭제 Video 수: {user_completed_videos_count}"
)
# 기본 조건: 현재 사용자 소유, completed 상태, 미삭제
base_conditions = [
Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
Video.is_deleted == False,
Project.is_deleted == False,
]
# 쿼리 1: 전체 개수 조회 (task_id 기준 고유 개수)
count_query = (
select(func.count(func.distinct(Video.task_id)))
.join(Project, Video.project_id == Project.id)
.where(*base_conditions)
)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - task_id 기준 고유 개수 (total): {total}")
# 서브쿼리: task_id별 최신 Video의 id 조회
subquery = (
select(func.max(Video.id).label("max_id"))
.join(Project, Video.project_id == Project.id)
.where(*base_conditions)
.group_by(Video.task_id)
.subquery()
)
# DEBUG: 서브쿼리 결과 확인
subquery_debug_result = await session.execute(select(subquery.c.max_id))
subquery_ids = [row[0] for row in subquery_debug_result.all()]
logger.debug(f"[get_videos] DEBUG - 서브쿼리 결과 (max_id 목록): {subquery_ids}")
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회
query = (
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(Video.id.in_(select(subquery.c.max_id)))
.order_by(Video.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
result = await session.execute(query)
rows = result.all()
logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}")
# VideoListItem으로 변환 (JOIN 결과에서 바로 추출)
items = []
for video, project in rows:
item = VideoListItem(
store_name=project.store_name,
region=project.region,
task_id=video.task_id,
result_movie_url=video.result_movie_url,
created_at=video.created_at,
)
items.append(item)
response = PaginatedResponse.create(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
logger.info(
f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, "
f"page_size: {pagination.page_size}, items_count: {len(items)}"
)
return response
except Exception as e:
logger.error(f"[get_videos] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
)
@router.delete(
"/videos/delete/{task_id}",
summary="아카이브 영상 소프트 삭제",
description="""
## 개요
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
(is_deleted=True로 설정, 실제 데이터는 DB에 유지)
## 소프트 삭제 대상 테이블
1. **Video**: 동일 task_id의 모든 영상
2. **SongTimestamp**: 관련 노래의 타임스탬프 (suno_audio_id 기준)
3. **Song**: 동일 task_id의 모든 노래
4. **Lyric**: 동일 task_id의 모든 가사
5. **Image**: 동일 task_id의 모든 이미지
6. **Project**: task_id에 해당하는 프로젝트
## 경로 파라미터
- **task_id**: 삭제할 프로젝트의 task_id (UUID7 형식)
## 참고
- 본인이 소유한 프로젝트만 삭제할 있습니다.
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
- 백그라운드에서 비동기로 처리됩니다.
""",
responses={
200: {"description": "삭제 요청 성공"},
403: {"description": "삭제 권한 없음"},
404: {"description": "프로젝트를 찾을 수 없음"},
500: {"description": "삭제 실패"},
},
)
async def delete_video(
task_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
"""task_id에 해당하는 프로젝트와 관련 데이터를 소프트 삭제합니다."""
logger.info(f"[delete_video] START - task_id: {task_id}, user: {current_user.user_uuid}")
logger.debug(f"[delete_video] DEBUG - current_user.user_uuid: {current_user.user_uuid}")
try:
# DEBUG: task_id로 조회 가능한 모든 Project 확인 (is_deleted 무관)
all_projects_result = await session.execute(
select(Project).where(Project.task_id == task_id)
)
all_projects = all_projects_result.scalars().all()
logger.debug(
f"[delete_video] DEBUG - task_id로 조회된 모든 Project 수: {len(all_projects)}"
)
for p in all_projects:
logger.debug(
f"[delete_video] DEBUG - Project: id={p.id}, task_id={p.task_id}, "
f"user_uuid={p.user_uuid}, is_deleted={p.is_deleted}"
)
# 프로젝트 조회
result = await session.execute(
select(Project).where(
Project.task_id == task_id,
Project.is_deleted == False,
)
)
project = result.scalar_one_or_none()
logger.debug(f"[delete_video] DEBUG - 조회된 Project (is_deleted=False): {project}")
if project is None:
logger.warning(f"[delete_video] NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail="프로젝트를 찾을 수 없습니다.",
)
logger.debug(
f"[delete_video] DEBUG - Project 상세: id={project.id}, "
f"user_uuid={project.user_uuid}, store_name={project.store_name}"
)
# 소유권 검증
if project.user_uuid != current_user.user_uuid:
logger.warning(
f"[delete_video] FORBIDDEN - task_id: {task_id}, "
f"owner: {project.user_uuid}, requester: {current_user.user_uuid}"
)
raise HTTPException(
status_code=403,
detail="삭제 권한이 없습니다.",
)
# DEBUG: 삭제 대상 데이터 수 미리 확인
video_count_result = await session.execute(
select(func.count(Video.id)).where(
Video.task_id == task_id, Video.is_deleted == False
)
)
video_count = video_count_result.scalar() or 0
logger.debug(f"[delete_video] DEBUG - 삭제 대상 Video 수: {video_count}")
# 백그라운드 태스크로 소프트 삭제 실행
background_tasks.add_task(soft_delete_by_task_id, task_id)
logger.info(f"[delete_video] ACCEPTED - task_id: {task_id}, soft delete scheduled")
return {
"message": "삭제 요청이 접수되었습니다. 백그라운드에서 처리됩니다.",
"task_id": task_id,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[delete_video] EXCEPTION - task_id: {task_id}, error: {e}")
raise HTTPException(
status_code=500,
detail=f"삭제에 실패했습니다: {str(e)}",
)

View File

@ -0,0 +1,185 @@
"""
Archive Worker 모듈
아카이브 관련 백그라운드 작업을 처리합니다.
- 소프트 삭제 (is_deleted=True 설정)
"""
from sqlalchemy import func, select, update
from app.database.session import BackgroundSessionLocal
from app.home.models import Image, Project
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.utils.logger import get_logger
from app.video.models import Video
logger = get_logger(__name__)
async def soft_delete_by_task_id(task_id: str) -> dict:
"""
task_id에 해당하는 모든 관련 데이터를 소프트 삭제합니다.
대상 테이블 (refresh_token, social_account, user 제외):
- Project
- Image
- Lyric
- Song
- SongTimestamp (suno_audio_id 기준)
- Video
Args:
task_id: 삭제할 프로젝트의 task_id
Returns:
dict: 테이블별 업데이트된 레코드
"""
logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}")
logger.debug(f"[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
result = {
"task_id": task_id,
"project": 0,
"image": 0,
"lyric": 0,
"song": 0,
"song_timestamp": 0,
"video": 0,
}
try:
async with BackgroundSessionLocal() as session:
# DEBUG: 삭제 전 각 테이블의 데이터 수 확인
video_before = await session.execute(
select(func.count(Video.id)).where(
Video.task_id == task_id, Video.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Video 수: {video_before.scalar() or 0}"
)
song_before = await session.execute(
select(func.count(Song.id)).where(
Song.task_id == task_id, Song.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Song 수: {song_before.scalar() or 0}"
)
lyric_before = await session.execute(
select(func.count(Lyric.id)).where(
Lyric.task_id == task_id, Lyric.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Lyric 수: {lyric_before.scalar() or 0}"
)
image_before = await session.execute(
select(func.count(Image.id)).where(
Image.task_id == task_id, Image.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Image 수: {image_before.scalar() or 0}"
)
project_before = await session.execute(
select(func.count(Project.id)).where(
Project.task_id == task_id, Project.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Project 수: {project_before.scalar() or 0}"
)
# 1. Video 소프트 삭제
video_stmt = (
update(Video)
.where(Video.task_id == task_id, Video.is_deleted == False)
.values(is_deleted=True)
)
video_result = await session.execute(video_stmt)
result["video"] = video_result.rowcount
logger.info(f"[soft_delete_by_task_id] Video soft deleted - count: {result['video']}")
logger.debug(f"[soft_delete_by_task_id] DEBUG - Video rowcount: {video_result.rowcount}")
# 2. SongTimestamp 소프트 삭제 (Song의 suno_audio_id 기준, 서브쿼리 사용)
suno_subquery = (
select(Song.suno_audio_id)
.where(
Song.task_id == task_id,
Song.suno_audio_id.isnot(None),
)
.scalar_subquery()
)
timestamp_stmt = (
update(SongTimestamp)
.where(
SongTimestamp.suno_audio_id.in_(suno_subquery),
SongTimestamp.is_deleted == False,
)
.values(is_deleted=True)
)
timestamp_result = await session.execute(timestamp_stmt)
result["song_timestamp"] = timestamp_result.rowcount
logger.info(
f"[soft_delete_by_task_id] SongTimestamp soft deleted - count: {result['song_timestamp']}"
)
# 3. Song 소프트 삭제
song_stmt = (
update(Song)
.where(Song.task_id == task_id, Song.is_deleted == False)
.values(is_deleted=True)
)
song_result = await session.execute(song_stmt)
result["song"] = song_result.rowcount
logger.info(f"[soft_delete_by_task_id] Song soft deleted - count: {result['song']}")
# 4. Lyric 소프트 삭제
lyric_stmt = (
update(Lyric)
.where(Lyric.task_id == task_id, Lyric.is_deleted == False)
.values(is_deleted=True)
)
lyric_result = await session.execute(lyric_stmt)
result["lyric"] = lyric_result.rowcount
logger.info(f"[soft_delete_by_task_id] Lyric soft deleted - count: {result['lyric']}")
# 5. Image 소프트 삭제
image_stmt = (
update(Image)
.where(Image.task_id == task_id, Image.is_deleted == False)
.values(is_deleted=True)
)
image_result = await session.execute(image_stmt)
result["image"] = image_result.rowcount
logger.info(f"[soft_delete_by_task_id] Image soft deleted - count: {result['image']}")
# 6. Project 소프트 삭제
project_stmt = (
update(Project)
.where(Project.task_id == task_id, Project.is_deleted == False)
.values(is_deleted=True)
)
project_result = await session.execute(project_stmt)
result["project"] = project_result.rowcount
logger.info(f"[soft_delete_by_task_id] Project soft deleted - count: {result['project']}")
await session.commit()
logger.info(
f"[soft_delete_by_task_id] SUCCESS - task_id: {task_id}, "
f"deleted: project={result['project']}, image={result['image']}, "
f"lyric={result['lyric']}, song={result['song']}, "
f"song_timestamp={result['song_timestamp']}, video={result['video']}"
)
return result
except Exception as e:
logger.error(f"[soft_delete_by_task_id] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
raise

View File

@ -72,16 +72,17 @@ async def create_db_tables():
import asyncio
# 모델 import (테이블 메타데이터 등록용)
from app.user.models import User, RefreshToken # noqa: F401
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
from app.home.models import Image, Project # noqa: F401
from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video # noqa: F401
# 생성할 테이블 목록 (SocialAccount 제외)
# 생성할 테이블 목록
tables_to_create = [
User.__table__,
RefreshToken.__table__,
SocialAccount.__table__,
Project.__table__,
Image.__table__,
Lyric.__table__,

View File

@ -658,6 +658,9 @@ async def upload_images(
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 요청 방식
multipart/form-data 형식으로 전송합니다.
@ -684,11 +687,13 @@ jpg, jpeg, png, webp, heic, heif
```bash
# 바이너리 파일만 업로드
curl -X POST "http://localhost:8000/image/upload/blob" \\
-H "Authorization: Bearer {access_token}" \\
-F "files=@/path/to/image1.jpg" \\
-F "files=@/path/to/image2.png"
# URL + 바이너리 파일 동시 업로드
curl -X POST "http://localhost:8000/image/upload/blob" \\
-H "Authorization: Bearer {access_token}" \\
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
-F "files=@/path/to/local_image.jpg"
```
@ -812,7 +817,7 @@ async def upload_images_blob(
img_order = len(url_images) # URL 이미지 다음 순서부터 시작
if valid_files_data:
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=current_user.user_uuid, task_id=task_id)
total_files = len(valid_files_data)
for idx, (original_name, ext, file_content) in enumerate(valid_files_data):

View File

@ -9,7 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -49,6 +49,7 @@ class Project(Base):
Index("idx_project_store_name", "store_name"),
Index("idx_project_region", "region"),
Index("idx_project_user_uuid", "user_uuid"),
Index("idx_project_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -113,6 +114,13 @@ class Project(Base):
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
@ -175,6 +183,7 @@ class Image(Base):
__tablename__ = "image"
__table_args__ = (
Index("idx_image_task_id", "task_id"),
Index("idx_image_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -193,7 +202,6 @@ class Image(Base):
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
unique=True,
comment="이미지 업로드 작업 고유 식별자 (UUID7)",
)
@ -216,6 +224,13 @@ class Image(Base):
comment="이미지 순서",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,

View File

@ -22,6 +22,7 @@ async def save_upload_file(file: UploadFile, save_path: Path) -> None:
async def upload_image_to_blob(
task_id: str,
user_uuid: str,
file: UploadFile,
filename: str,
save_dir: Path,
@ -31,6 +32,7 @@ async def upload_image_to_blob(
Args:
task_id: 작업 고유 식별자
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
file: 업로드할 파일 객체
filename: 저장될 파일명
save_dir: media 저장 디렉토리 경로
@ -46,7 +48,7 @@ async def upload_image_to_blob(
await save_upload_file(file, save_path)
# 2. Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_image(file_path=str(save_path))
if upload_success:

View File

@ -174,6 +174,9 @@ async def get_lyric_by_task_id(
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 요청 필드
- **task_id**: 작업 고유 식별자 (이미지 업로드 생성된 task_id, 필수)
- **customer_name**: 고객명/가게명 (필수)
@ -192,16 +195,18 @@ async def get_lyric_by_task_id(
- GET /lyric/status/{task_id} 처리 상태 확인
- GET /lyric/{task_id} 생성된 가사 조회
## 사용 예시
```
POST /lyric/generate
{
## 사용 예시 (cURL)
```bash
curl -X POST "http://localhost:8000/api/v1/lyric/generate" \\
-H "Authorization: Bearer {access_token}" \\
-H "Content-Type: application/json" \\
-d '{
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean"
}
}'
```
## 응답 예시
@ -296,6 +301,7 @@ async def generate_lyric(
task_id=task_id,
detail_region_info=request_body.detail_region_info,
language=request_body.language,
user_uuid=current_user.user_uuid,
)
session.add(project)
await session.commit()
@ -376,14 +382,18 @@ async def generate_lyric(
description="""
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 상태 값
- **processing**: 가사 생성
- **completed**: 가사 생성 완료
- **failed**: 가사 생성 실패
## 사용 예시
```
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/api/v1/lyric/status/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}"
```
""",
response_model=LyricStatusResponse,
@ -407,6 +417,9 @@ async def get_lyric_status(
description="""
생성 완료된 가사를 페이지네이션으로 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 20, 최대: 100)
@ -420,11 +433,19 @@ async def get_lyric_status(
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /lyrics/ # 기본 조회 (1페이지, 20개)
GET /lyrics/?page=2 # 2페이지 조회
GET /lyrics/?page=1&page_size=50 # 50개씩 조회
## 사용 예시 (cURL)
```bash
# 기본 조회 (1페이지, 20개)
curl -X GET "http://localhost:8000/api/v1/lyrics/" \\
-H "Authorization: Bearer {access_token}"
# 2페이지 조회
curl -X GET "http://localhost:8000/api/v1/lyrics/?page=2" \\
-H "Authorization: Bearer {access_token}"
# 50개씩 조회
curl -X GET "http://localhost:8000/api/v1/lyrics/?page=1&page_size=50" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
@ -461,6 +482,9 @@ async def list_lyrics(
description="""
task_id로 생성된 가사의 상세 정보를 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 반환 정보
- **id**: 가사 ID
- **task_id**: 작업 고유 식별자
@ -470,9 +494,10 @@ task_id로 생성된 가사의 상세 정보를 조회합니다.
- **lyric_result**: 생성된 가사 (완료 )
- **created_at**: 생성 일시
## 사용 예시
```
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/api/v1/lyric/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}"
```
""",
response_model=LyricDetailResponse,

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -39,6 +39,7 @@ class Lyric(Base):
__table_args__ = (
Index("idx_lyric_task_id", "task_id"),
Index("idx_lyric_project_id", "project_id"),
Index("idx_lyric_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -64,7 +65,6 @@ class Lyric(Base):
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
unique=True,
comment="가사 생성 작업 고유 식별자 (UUID7)",
)
@ -93,6 +93,13 @@ class Lyric(Base):
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=True,

View File

@ -42,6 +42,9 @@ router = APIRouter(prefix="/song", tags=["Song"])
description="""
Suno API를 통해 노래 생성을 요청합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 사용
@ -56,14 +59,16 @@ Suno API를 통해 노래 생성을 요청합니다.
- **song_id**: Suno API 작업 ID (상태 조회에 사용)
- **message**: 응답 메시지
## 사용 예시
```
POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
{
## 사용 예시 (cURL)
```bash
curl -X POST "http://localhost:8000/api/v1/song/generate/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}" \\
-H "Content-Type: application/json" \\
-d '{
"lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께",
"genre": "K-Pop",
"language": "Korean"
}
}'
```
## 참고
@ -316,6 +321,9 @@ async def generate_song(
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **song_id**: 노래 생성 반환된 Suno API 작업 ID (필수)
@ -324,9 +332,10 @@ SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
- **status**: Suno API 작업 상태
- **message**: 상태 메시지
## 사용 예시
```
GET /song/status/abc123...
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/api/v1/song/status/{song_id}" \\
-H "Authorization: Bearer {access_token}"
```
## 상태 값 (Suno API 응답)
@ -425,6 +434,7 @@ async def get_song_status(
suno_task_id=song_id,
audio_url=audio_url,
store_name=store_name,
user_uuid=current_user.user_uuid,
duration=clip_duration,
)
logger.info(
@ -471,6 +481,19 @@ async def get_song_status(
for order_idx, timestamped_lyric in enumerate(
timestamped_lyrics
):
# start_sec 또는 end_sec가 None인 경우 건너뛰기
if (
timestamped_lyric["start_sec"] is None
or timestamped_lyric["end_sec"] is None
):
logger.warning(
f"[get_song_status] Skipping timestamp - "
f"lyric_line: {timestamped_lyric['text']}, "
f"start_sec: {timestamped_lyric['start_sec']}, "
f"end_sec: {timestamped_lyric['end_sec']}"
)
continue
song_timestamp = SongTimestamp(
suno_audio_id=suno_audio_id,
order_idx=order_idx,

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -42,6 +42,7 @@ class Song(Base):
Index("idx_song_task_id", "task_id"),
Index("idx_song_project_id", "project_id"),
Index("idx_song_lyric_id", "lyric_id"),
Index("idx_song_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -74,7 +75,6 @@ class Song(Base):
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
unique=True,
comment="노래 생성 작업 고유 식별자 (UUID7)",
)
@ -120,6 +120,13 @@ class Song(Base):
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
@ -180,6 +187,7 @@ class SongTimestamp(Base):
__tablename__ = "song_timestamp"
__table_args__ = (
Index("idx_song_timestamp_suno_audio_id", "suno_audio_id"),
Index("idx_song_timestamp_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -225,6 +233,13 @@ class SongTimestamp(Base):
comment="가사 종료 시점 (초)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,

View File

@ -177,6 +177,7 @@ async def download_and_upload_song_by_suno_task_id(
suno_task_id: str,
audio_url: str,
store_name: str,
user_uuid: str,
duration: float | None = None,
) -> None:
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
@ -185,6 +186,7 @@ async def download_and_upload_song_by_suno_task_id(
suno_task_id: Suno API 작업 ID
audio_url: 다운로드할 오디오 URL
store_name: 저장할 파일명에 사용할 업체명
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
duration: 노래 재생 시간 ()
"""
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
@ -233,7 +235,7 @@ async def download_and_upload_song_by_suno_task_id(
logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
if not upload_success:

View File

@ -439,6 +439,7 @@ class SocialAccount(Base):
Index("idx_social_account_user_uuid", "user_uuid"),
Index("idx_social_account_platform", "platform"),
Index("idx_social_account_is_active", "is_active"),
Index("idx_social_account_is_deleted", "is_deleted"),
Index(
"uq_user_platform_account",
"user_uuid",
@ -541,6 +542,13 @@ class SocialAccount(Base):
comment="연동 활성화 상태 (비활성화 시 사용 중지)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
# ==========================================================================
# 시간 정보
# ==========================================================================

View File

@ -488,7 +488,8 @@ class CreatomateService:
json=payload,
)
if response.status_code == 200 or response.status_code == 201:
# 200 OK, 201 Created, 202 Accepted 모두 성공으로 처리
if response.status_code in (200, 201, 202):
return response.json()
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
@ -557,7 +558,8 @@ class CreatomateService:
json=source,
)
if response.status_code == 200 or response.status_code == 201:
# 200 OK, 201 Created, 202 Accepted 모두 성공으로 처리
if response.status_code in (200, 201, 202):
return response.json()
# 재시도 불가능한 오류 (4xx 클라이언트 오류)

View File

@ -52,6 +52,9 @@ router = APIRouter(prefix="/video", tags=["Video"])
description="""
Creatomate API를 통해 영상 생성을 요청합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **task_id**: Project/Lyric/Song/Image의 task_id (필수) - 연관된 프로젝트, 가사, 노래, 이미지를 조회하는 사용
@ -70,10 +73,15 @@ Creatomate API를 통해 영상 생성을 요청합니다.
- **creatomate_render_id**: Creatomate 렌더 ID (상태 조회에 사용)
- **message**: 응답 메시지
## 사용 예시
```
GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431
GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal
## 사용 예시 (cURL)
```bash
# 세로형 영상 생성 (기본값)
curl -X GET "http://localhost:8000/api/v1/video/generate/0694b716-dbff-7219-8000-d08cb5fce431" \\
-H "Authorization: Bearer {access_token}"
# 가로형 영상 생성
curl -X GET "http://localhost:8000/api/v1/video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
@ -350,9 +358,9 @@ async def generate_video(
)
final_template["source"]["elements"].append(caption)
logger.debug(
f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
)
# logger.debug(
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
# )
# 6-5. 커스텀 렌더링 요청 (비동기)
render_response = await creatomate_service.make_creatomate_custom_call_async(
@ -457,6 +465,9 @@ async def generate_video(
Creatomate API를 통해 영상 생성 작업의 상태를 조회합니다.
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블을 업데이트합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **creatomate_render_id**: 영상 생성 반환된 Creatomate 렌더 ID (필수)
@ -467,9 +478,10 @@ succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하
- **render_data**: 렌더링 결과 데이터 (완료 )
- **raw_response**: Creatomate API 원본 응답
## 사용 예시
```
GET /video/status/render-id-123...
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/api/v1/video/status/{creatomate_render_id}" \\
-H "Authorization: Bearer {access_token}"
```
## 상태 값
@ -554,6 +566,7 @@ async def get_video_status(
task_id=video.task_id,
video_url=video_url,
store_name=store_name,
user_uuid=current_user.user_uuid,
)
elif video and video.status == "completed":
logger.debug(
@ -602,6 +615,9 @@ async def get_video_status(
task_id를 기반으로 Video 테이블의 상태를 polling하고,
completed인 경우 Project 정보와 영상 URL을 반환합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **task_id**: 프로젝트 task_id (필수)
@ -615,9 +631,10 @@ completed인 경우 Project 정보와 영상 URL을 반환합니다.
- **result_movie_url**: 영상 결과 URL (completed )
- **created_at**: 생성 일시
## 사용 예시
```
GET /video/download/019123ab-cdef-7890-abcd-ef1234567890
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/api/v1/video/download/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
@ -718,6 +735,9 @@ async def download_video(
description="""
완료된 영상 목록을 페이지네이션하여 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 쿼리 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
@ -731,9 +751,10 @@ async def download_video(
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /videos/?page=1&page_size=10
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/api/v1/videos/?page=1&page_size=10" \\
-H "Authorization: Bearer {access_token}"
```
## 참고

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -41,6 +41,7 @@ class Video(Base):
Index("idx_video_project_id", "project_id"),
Index("idx_video_lyric_id", "lyric_id"),
Index("idx_video_song_id", "song_id"),
Index("idx_video_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -80,7 +81,6 @@ class Video(Base):
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
unique=True,
comment="영상 생성 작업 고유 식별자 (UUID7)",
)
@ -102,6 +102,13 @@ class Video(Base):
comment="생성된 영상 URL",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,

View File

@ -106,6 +106,7 @@ async def download_and_upload_video_to_blob(
task_id: str,
video_url: str,
store_name: str,
user_uuid: str,
) -> None:
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
@ -113,6 +114,7 @@ async def download_and_upload_video_to_blob(
task_id: 프로젝트 task_id
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
"""
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
@ -142,7 +144,7 @@ async def download_and_upload_video_to_blob(
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
if not upload_success:
@ -190,6 +192,7 @@ async def download_and_upload_video_by_creatomate_render_id(
creatomate_render_id: str,
video_url: str,
store_name: str,
user_uuid: str,
) -> None:
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
@ -197,6 +200,7 @@ async def download_and_upload_video_by_creatomate_render_id(
creatomate_render_id: Creatomate API 렌더 ID
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
"""
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
temp_file_path: Path | None = None
@ -244,7 +248,7 @@ async def download_and_upload_video_by_creatomate_render_id(
logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
if not upload_success:

View File

@ -0,0 +1,75 @@
-- ============================================================================
-- 마이그레이션: is_deleted 필드 추가 (소프트 삭제 지원)
-- 생성일: 2026-01-28
-- 설명: 모든 테이블(refresh_token 제외)에 is_deleted 필드 및 인덱스 추가
-- ============================================================================
-- 주의: 이 마이그레이션을 실행하기 전에 데이터베이스 백업을 권장합니다.
-- ============================================================================
-- 1. Project 테이블
-- ============================================================================
ALTER TABLE project
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_project_is_deleted ON project(is_deleted);
-- ============================================================================
-- 2. Image 테이블
-- ============================================================================
ALTER TABLE image
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_image_is_deleted ON image(is_deleted);
-- ============================================================================
-- 3. Lyric 테이블
-- ============================================================================
ALTER TABLE lyric
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_lyric_is_deleted ON lyric(is_deleted);
-- ============================================================================
-- 4. Song 테이블
-- ============================================================================
ALTER TABLE song
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_song_is_deleted ON song(is_deleted);
-- ============================================================================
-- 5. SongTimestamp 테이블
-- ============================================================================
ALTER TABLE song_timestamp
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_song_timestamp_is_deleted ON song_timestamp(is_deleted);
-- ============================================================================
-- 6. Video 테이블
-- ============================================================================
ALTER TABLE video
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_video_is_deleted ON video(is_deleted);
-- ============================================================================
-- 7. SocialAccount 테이블
-- ============================================================================
ALTER TABLE social_account
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_social_account_is_deleted ON social_account(is_deleted);
-- ============================================================================
-- 검증 쿼리 (마이그레이션 후 실행하여 확인)
-- ============================================================================
-- SELECT
-- TABLE_NAME,
-- COLUMN_NAME,
-- DATA_TYPE,
-- COLUMN_DEFAULT
-- FROM INFORMATION_SCHEMA.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE()
-- AND COLUMN_NAME = 'is_deleted';

View File

@ -0,0 +1,143 @@
# 소프트 삭제 (Soft Delete) 가이드
## 개요
소프트 삭제는 데이터를 실제로 삭제하지 않고 `is_deleted` 필드를 `True`로 설정하여 삭제된 것처럼 처리하는 방식입니다.
이를 통해 데이터 복구가 가능하고, 삭제 이력을 추적할 수 있습니다.
## 적용 테이블
| 테이블 | is_deleted | 인덱스 | 비고 |
|--------|------------|--------|------|
| User | ✅ | idx_user_is_deleted | deleted_at 필드도 포함 |
| Project | ✅ | idx_project_is_deleted | |
| Image | ✅ | idx_image_is_deleted | |
| Lyric | ✅ | idx_lyric_is_deleted | |
| Song | ✅ | idx_song_is_deleted | |
| SongTimestamp | ✅ | idx_song_timestamp_is_deleted | |
| Video | ✅ | idx_video_is_deleted | |
| SocialAccount | ✅ | idx_social_account_is_deleted | |
| RefreshToken | ❌ | - | 토큰은 is_revoked로 관리 |
## 필드 정의
```python
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
```
## API 엔드포인트
### 아카이브 삭제 API
```
DELETE /archive/videos/delete/{task_id}
```
task_id에 해당하는 모든 관련 데이터를 소프트 삭제합니다.
백그라운드에서 비동기로 처리됩니다.
## 백그라운드 태스크
### soft_delete_by_task_id
위치: `app/archive/worker/archive_task.py`
```python
from app.archive.worker.archive_task import soft_delete_by_task_id
# 백그라운드 태스크로 실행
background_tasks.add_task(soft_delete_by_task_id, task_id)
```
삭제 대상 테이블 (순서대로):
1. Video
2. SongTimestamp (suno_audio_id 기준)
3. Song
4. Lyric
5. Image
6. Project
## 사용 예시
### 소프트 삭제 수행
```python
async def soft_delete_project(session: AsyncSession, project_id: int) -> None:
"""프로젝트를 소프트 삭제합니다."""
stmt = (
update(Project)
.where(Project.id == project_id)
.values(is_deleted=True)
)
await session.execute(stmt)
await session.commit()
```
### 삭제되지 않은 데이터만 조회
```python
async def get_active_projects(session: AsyncSession) -> list[Project]:
"""삭제되지 않은 프로젝트만 조회합니다."""
stmt = select(Project).where(Project.is_deleted == False)
result = await session.execute(stmt)
return result.scalars().all()
```
### 삭제된 데이터 복구
```python
async def restore_project(session: AsyncSession, project_id: int) -> None:
"""삭제된 프로젝트를 복구합니다."""
stmt = (
update(Project)
.where(Project.id == project_id)
.values(is_deleted=False)
)
await session.execute(stmt)
await session.commit()
```
## 쿼리 시 주의사항
1. **기본 조회 시 is_deleted 필터 추가**
- 모든 조회 쿼리에서 `is_deleted == False` 조건을 명시적으로 추가해야 합니다.
2. **관리자 기능에서만 삭제된 데이터 포함**
- 일반 사용자 API에서는 삭제된 데이터가 노출되지 않도록 해야 합니다.
3. **CASCADE 삭제와의 관계**
- 부모 테이블 소프트 삭제 시 자식 테이블도 함께 소프트 삭제하는 로직 필요
- 또는 부모만 소프트 삭제하고 자식은 JOIN 시 필터링
## 마이그레이션
기존 데이터베이스에 `is_deleted` 필드를 추가하려면:
```bash
# 마이그레이션 SQL 실행
mysql -u <user> -p <database> < docs/database-schema/migration_add_is_deleted.sql
```
또는 Alembic 마이그레이션 사용:
```bash
alembic revision --autogenerate -m "Add is_deleted field to all tables"
alembic upgrade head
```
## 인덱스 활용
`is_deleted` 필드에 인덱스가 생성되어 있으므로, 다음과 같은 쿼리가 효율적으로 실행됩니다:
```sql
-- 삭제되지 않은 프로젝트 조회 (인덱스 활용)
SELECT * FROM project WHERE is_deleted = FALSE;
-- 복합 조건 (task_id + is_deleted)
SELECT * FROM project WHERE task_id = 'xxx' AND is_deleted = FALSE;
```

61
main.py
View File

@ -11,6 +11,7 @@ from app.database.session import engine
# User 모델 import (테이블 메타데이터 등록용)
from app.user.models import User, RefreshToken # noqa: F401
from app.archive.api.routers.v1.archive import router as archive_router
from app.home.api.routers.v1.home import router as home_router
from app.user.api.routers.v1.auth import router as auth_router, test_router as auth_test_router
from app.lyric.api.routers.v1.lyric import router as lyric_router
@ -36,6 +37,14 @@ tags_metadata = [
- **Access Token**: 1시간 유효, API 호출 사용
- **Refresh Token**: 7 유효, Access Token 갱신 사용
## Scalar에서 인증 사용하기
1. 카카오 로그인 또는 테스트 토큰 발급으로 `access_token` 획득
2. 우측 상단 **Authorize** 버튼 클릭
3. `access_token` 입력 (Bearer 접두사 없이 토큰만 입력)
4. **Authorize** 클릭하여 저장
5. 이후 인증이 필요한 API 호출 자동으로 토큰이 포함됨
""",
},
# {
@ -44,16 +53,24 @@ tags_metadata = [
# },
{
"name": "Crawling",
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
"description": """네이버 지도 크롤링 API - 장소 정보 및 이미지 수집
**인증: 불필요** (공개 API)
""",
},
{
"name": "Image-Blob",
"description": "이미지 업로드 API - Azure Blob Storage",
"description": """이미지 업로드 API - Azure Blob Storage
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
""",
},
{
"name": "Lyric",
"description": """가사 생성 및 관리 API
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 가사 생성 흐름
1. `POST /api/v1/lyric/generate` - 가사 생성 요청 (백그라운드 처리)
@ -65,6 +82,8 @@ tags_metadata = [
"name": "Song",
"description": """노래 생성 및 관리 API (Suno AI)
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 노래 생성 흐름
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
@ -75,11 +94,32 @@ tags_metadata = [
"name": "Video",
"description": """영상 생성 및 관리 API (Creatomate)
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 영상 생성 흐름
1. `GET /api/v1/video/generate/{task_id}` - 영상 생성 요청
2. `GET /api/v1/video/status/{creatomate_render_id}` - Creatomate 상태 확인
3. `GET /api/v1/video/download/{task_id}` - 영상 다운로드 URL 조회
""",
},
{
"name": "Archive",
"description": """아카이브 API - 완료된 영상 목록 조회 및 삭제
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 주요 기능
- `GET /api/v1/archive/videos/` - 완료된 영상 목록 페이지네이션 조회
- `DELETE /api/v1/archive/videos/{task_id}` - 아카이브 영상 삭제 (CASCADE)
## 참고
- status가 'completed' 영상만 반환됩니다.
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
- created_at 기준 내림차순 정렬됩니다.
- 삭제 관련 Lyric, Song, Video가 CASCADE로 함께 삭제됩니다.
""",
},
]
@ -137,12 +177,24 @@ def custom_openapi():
}
}
# 인증이 필요하지 않은 엔드포인트 (공개 API)
public_endpoints = [
"/auth/kakao/login",
"/auth/kakao/callback",
"/auth/kakao/verify",
"/auth/refresh",
"/auth/test/", # 테스트 엔드포인트
"/crawling",
"/autocomplete",
]
# 보안이 필요한 엔드포인트에 security 적용
for path, path_item in openapi_schema["paths"].items():
for method, operation in path_item.items():
if method in ["get", "post", "put", "patch", "delete"]:
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
if any(auth_path in path for auth_path in ["/auth/me", "/auth/logout"]):
# 공개 엔드포인트가 아닌 경우 인증 필요
is_public = any(public_path in path for public_path in public_endpoints)
if not is_public and path.startswith("/api/"):
operation["security"] = [{"BearerAuth": []}]
app.openapi_schema = openapi_schema
@ -182,6 +234,7 @@ app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
app.include_router(lyric_router)
app.include_router(song_router)
app.include_router(video_router)
app.include_router(archive_router) # Archive API 라우터 추가
# DEBUG 모드에서만 테스트 라우터 등록
if prj_settings.DEBUG: