finish
parent
32ae5530b6
commit
c07a2f6dae
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)}",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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__,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: 삭제됨)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 시간 정보
|
||||
# ==========================================================================
|
||||
|
|
|
|||
|
|
@ -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 클라이언트 오류)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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
61
main.py
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue