Compare commits
No commits in common. "db853e660425f9bb59842a72e1dd6db4c324e5bb" and "df3bfda594986c47ceb8dbe683bd114f8be29301" have entirely different histories.
db853e6604
...
df3bfda594
|
|
@ -1,337 +0,0 @@
|
|||
"""
|
||||
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": "영상 목록 조회 성공"},
|
||||
401: {"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": "삭제 요청 성공"},
|
||||
401: {"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)}",
|
||||
)
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
"""
|
||||
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,32 +72,18 @@ async def create_db_tables():
|
|||
import asyncio
|
||||
|
||||
# 모델 import (테이블 메타데이터 등록용)
|
||||
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
||||
from app.home.models import Image, Project # noqa: F401
|
||||
# 주의: User를 먼저 import해야 UserProject가 User를 참조할 수 있음
|
||||
from app.user.models import User # noqa: F401
|
||||
from app.home.models import Image, Project, UserProject # noqa: F401
|
||||
from app.lyric.models import Lyric # noqa: F401
|
||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
||||
from app.song.models import Song # noqa: F401
|
||||
from app.video.models import Video # noqa: F401
|
||||
|
||||
# 생성할 테이블 목록
|
||||
tables_to_create = [
|
||||
User.__table__,
|
||||
RefreshToken.__table__,
|
||||
SocialAccount.__table__,
|
||||
Project.__table__,
|
||||
Image.__table__,
|
||||
Lyric.__table__,
|
||||
Song.__table__,
|
||||
SongTimestamp.__table__,
|
||||
Video.__table__,
|
||||
]
|
||||
|
||||
logger.info("Creating database tables...")
|
||||
|
||||
async with asyncio.timeout(10):
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(
|
||||
lambda conn: Base.metadata.create_all(conn, tables=tables_to_create)
|
||||
)
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
# FastAPI 의존성용 세션 제너레이터
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.home.models import Image, Project
|
||||
from app.home.models import Image, Project, UserProject
|
||||
|
||||
|
||||
class ProjectAdmin(ModelView, model=Project):
|
||||
|
|
@ -100,3 +100,44 @@ class ImageAdmin(ModelView, model=Image):
|
|||
"img_url": "이미지 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
||||
|
||||
class UserProjectAdmin(ModelView, model=UserProject):
|
||||
name = "사용자-프로젝트"
|
||||
name_plural = "사용자-프로젝트 목록"
|
||||
icon = "fa-solid fa-link"
|
||||
category = "프로젝트 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
"user",
|
||||
"project",
|
||||
]
|
||||
|
||||
column_searchable_list = [
|
||||
UserProject.user_id,
|
||||
UserProject.project_id,
|
||||
]
|
||||
|
||||
column_sortable_list = [
|
||||
UserProject.id,
|
||||
UserProject.user_id,
|
||||
UserProject.project_id,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_id": "사용자 ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"user": "사용자",
|
||||
"project": "프로젝트",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.database.session import get_session, AsyncSessionLocal
|
||||
from app.home.models import Image
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
from app.home.schemas.home_schema import (
|
||||
AutoCompleteRequest,
|
||||
CrawlingRequest,
|
||||
|
|
@ -502,7 +500,6 @@ async def upload_images(
|
|||
files: Optional[list[UploadFile]] = File(
|
||||
default=None, description="이미지 바이너리 파일 목록"
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ImageUploadResponse:
|
||||
"""이미지 업로드 (URL + 바이너리 파일)"""
|
||||
|
|
@ -656,9 +653,6 @@ async def upload_images(
|
|||
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
|
||||
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 요청 방식
|
||||
multipart/form-data 형식으로 전송합니다.
|
||||
|
||||
|
|
@ -685,13 +679,11 @@ 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"
|
||||
```
|
||||
|
|
@ -714,7 +706,6 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|||
responses={
|
||||
200: {"description": "이미지 업로드 성공"},
|
||||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
},
|
||||
tags=["Image-Blob"],
|
||||
openapi_extra={
|
||||
|
|
@ -737,7 +728,6 @@ async def upload_images_blob(
|
|||
default=None,
|
||||
description="이미지 바이너리 파일 목록",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ImageUploadResponse:
|
||||
"""이미지 업로드 (URL + Azure Blob Storage)
|
||||
|
||||
|
|
@ -816,7 +806,7 @@ async def upload_images_blob(
|
|||
img_order = len(url_images) # URL 이미지 다음 순서부터 시작
|
||||
|
||||
if valid_files_data:
|
||||
uploader = AzureBlobUploader(user_uuid=current_user.user_uuid, task_id=task_id)
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
total_files = len(valid_files_data)
|
||||
|
||||
for idx, (original_name, ext, file_content) in enumerate(valid_files_data):
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ Home 모듈 SQLAlchemy 모델 정의
|
|||
이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
|
||||
- Project: 프로젝트(사용자 입력 이력) 관리
|
||||
- Image: 업로드된 이미지 URL 관리
|
||||
- UserProject: User와 Project 간 M:N 관계 중계 테이블
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
|
@ -20,6 +21,122 @@ if TYPE_CHECKING:
|
|||
from app.user.models import User
|
||||
from app.video.models import Video
|
||||
|
||||
# =============================================================================
|
||||
# User-Project M:N 관계 중계 테이블
|
||||
# =============================================================================
|
||||
#
|
||||
# 설계 의도:
|
||||
# - User와 Project는 다대다(M:N) 관계입니다.
|
||||
# - 한 사용자는 여러 프로젝트에 참여할 수 있습니다.
|
||||
# - 한 프로젝트에는 여러 사용자가 참여할 수 있습니다.
|
||||
#
|
||||
# 중계 테이블 역할:
|
||||
# - UserProject 테이블이 두 테이블 간의 관계를 연결합니다.
|
||||
# - 각 레코드는 특정 사용자와 특정 프로젝트의 연결을 나타냅니다.
|
||||
# - 추가 속성(role, joined_at)으로 관계의 메타데이터를 저장합니다.
|
||||
#
|
||||
# 외래키 설정:
|
||||
# - user_id: User 테이블의 id를 참조 (ON DELETE CASCADE)
|
||||
# - project_id: Project 테이블의 id를 참조 (ON DELETE CASCADE)
|
||||
# - CASCADE 설정으로 부모 레코드 삭제 시 중계 레코드도 자동 삭제됩니다.
|
||||
#
|
||||
# 관계 방향:
|
||||
# - User.projects → UserProject → Project (사용자가 참여한 프로젝트 목록)
|
||||
# - Project.users → UserProject → User (프로젝트에 참여한 사용자 목록)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class UserProject(Base):
|
||||
"""
|
||||
User-Project M:N 관계 중계 테이블
|
||||
|
||||
사용자와 프로젝트 간의 다대다 관계를 관리합니다.
|
||||
한 사용자는 여러 프로젝트에 참여할 수 있고,
|
||||
한 프로젝트에는 여러 사용자가 참여할 수 있습니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
user_id: 사용자 외래키 (User.id 참조)
|
||||
project_id: 프로젝트 외래키 (Project.id 참조)
|
||||
role: 프로젝트 내 사용자 역할 (owner: 소유자, member: 멤버, viewer: 조회자)
|
||||
joined_at: 프로젝트 참여 일시
|
||||
|
||||
외래키 제약조건:
|
||||
- user_id → user.id (ON DELETE CASCADE)
|
||||
- project_id → project.id (ON DELETE CASCADE)
|
||||
|
||||
유니크 제약조건:
|
||||
- (user_id, project_id) 조합은 유일해야 함 (중복 참여 방지)
|
||||
"""
|
||||
|
||||
__tablename__ = "user_project"
|
||||
__table_args__ = (
|
||||
Index("idx_user_project_user_id", "user_id"),
|
||||
Index("idx_user_project_project_id", "project_id"),
|
||||
Index("idx_user_project_user_project", "user_id", "project_id", unique=True),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
autoincrement=True,
|
||||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
# 외래키: User 테이블 참조
|
||||
# - BigInteger 사용 (User.id가 BigInteger이므로 타입 일치 필요)
|
||||
# - ondelete="CASCADE": User 삭제 시 연결된 UserProject 레코드도 삭제
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 외래키 (User.id 참조)",
|
||||
)
|
||||
|
||||
# 외래키: Project 테이블 참조
|
||||
# - Integer 사용 (Project.id가 Integer이므로 타입 일치 필요)
|
||||
# - ondelete="CASCADE": Project 삭제 시 연결된 UserProject 레코드도 삭제
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="프로젝트 외래키 (Project.id 참조)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Relationships (관계 설정)
|
||||
# ==========================================================================
|
||||
# back_populates: 양방향 관계 설정 (User.user_projects, Project.user_projects)
|
||||
# lazy="selectin": N+1 문제 방지를 위한 즉시 로딩
|
||||
# ==========================================================================
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="user_projects",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
project: Mapped["Project"] = relationship(
|
||||
"Project",
|
||||
back_populates="user_projects",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<UserProject("
|
||||
f"id={self.id}, "
|
||||
f"user_id={self.user_id}, "
|
||||
f"project_id={self.project_id}, "
|
||||
f"role='{self.role}'"
|
||||
f")>"
|
||||
)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""
|
||||
|
|
@ -32,15 +149,16 @@ class Project(Base):
|
|||
id: 고유 식별자 (자동 증가)
|
||||
store_name: 고객명 (필수)
|
||||
region: 지역명 (필수, 예: 서울, 부산, 대구 등)
|
||||
task_id: 작업 고유 식별자 (UUID7 형식, 36자)
|
||||
task_id: 작업 고유 식별자 (UUID 형식, 36자)
|
||||
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
||||
Relationships:
|
||||
owner: 프로젝트 소유자 (User, 1:N 관계)
|
||||
lyrics: 생성된 가사 목록
|
||||
songs: 생성된 노래 목록
|
||||
videos: 최종 영상 결과 목록
|
||||
user_projects: User와의 M:N 관계 (중계 테이블 통한 연결)
|
||||
users: 프로젝트에 참여한 사용자 목록 (Association Proxy)
|
||||
"""
|
||||
|
||||
__tablename__ = "project"
|
||||
|
|
@ -48,8 +166,6 @@ class Project(Base):
|
|||
Index("idx_project_task_id", "task_id"),
|
||||
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",
|
||||
|
|
@ -68,37 +184,21 @@ class Project(Base):
|
|||
store_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="가게명",
|
||||
)
|
||||
|
||||
region: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="지역명 (예: 군산)",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="프로젝트 작업 고유 식별자 (UUID7)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# User 1:N 관계 (한 사용자가 여러 프로젝트를 소유)
|
||||
# ==========================================================================
|
||||
user_uuid: Mapped[Optional[str]] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("user.user_uuid", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="프로젝트 소유자 (User.user_uuid 외래키)",
|
||||
)
|
||||
|
||||
# 소유자 관계 설정 (User.projects와 양방향 연결)
|
||||
owner: Mapped[Optional["User"]] = relationship(
|
||||
"User",
|
||||
back_populates="projects",
|
||||
lazy="selectin",
|
||||
comment="프로젝트 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
||||
|
|
@ -114,13 +214,6 @@ 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,
|
||||
|
|
@ -150,6 +243,20 @@ class Project(Base):
|
|||
lazy="selectin",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# User M:N 관계 (중계 테이블 UserProject 통한 연결)
|
||||
# ==========================================================================
|
||||
# back_populates: UserProject.project와 양방향 연결
|
||||
# cascade: Project 삭제 시 UserProject 레코드도 삭제 (User는 유지)
|
||||
# lazy="selectin": N+1 문제 방지
|
||||
# ==========================================================================
|
||||
user_projects: Mapped[List["UserProject"]] = relationship(
|
||||
"UserProject",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||
if value is None:
|
||||
|
|
@ -174,7 +281,7 @@ class Image(Base):
|
|||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID7)
|
||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
|
||||
img_name: 이미지명
|
||||
img_url: 이미지 URL (S3, CDN 등의 경로)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
|
@ -182,8 +289,6 @@ 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",
|
||||
|
|
@ -202,7 +307,7 @@ class Image(Base):
|
|||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="이미지 업로드 작업 고유 식별자 (UUID7)",
|
||||
comment="이미지 업로드 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
img_name: Mapped[str] = mapped_column(
|
||||
|
|
@ -224,13 +329,6 @@ 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,7 +22,6 @@ 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,
|
||||
|
|
@ -32,7 +31,6 @@ async def upload_image_to_blob(
|
|||
|
||||
Args:
|
||||
task_id: 작업 고유 식별자
|
||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||
file: 업로드할 파일 객체
|
||||
filename: 저장될 파일명
|
||||
save_dir: media 저장 디렉토리 경로
|
||||
|
|
@ -48,7 +46,7 @@ async def upload_image_to_blob(
|
|||
await save_upload_file(file, save_path)
|
||||
|
||||
# 2. Azure Blob Storage에 업로드
|
||||
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
upload_success = await uploader.upload_image(file_path=str(save_path))
|
||||
|
||||
if upload_success:
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ Lyric API Router
|
|||
- POST /lyric/generate: 가사 생성
|
||||
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
|
||||
- GET /lyric/{task_id}: 가사 상세 조회
|
||||
- GET /lyric/list: 가사 목록 조회 (페이지네이션)
|
||||
- GET /lyrics: 가사 목록 조회 (페이지네이션)
|
||||
|
||||
사용 예시:
|
||||
from app.lyric.api.routers.v1.lyric import router
|
||||
app.include_router(router)
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
다른 서비스에서 재사용:
|
||||
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
|
||||
|
|
@ -31,8 +31,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.database.session import get_session
|
||||
from app.home.models import Project
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
from app.lyric.models import Lyric
|
||||
from app.lyric.schemas.lyric import (
|
||||
GenerateLyricRequest,
|
||||
|
|
@ -174,9 +172,6 @@ async def get_lyric_by_task_id(
|
|||
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
||||
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 요청 필드
|
||||
- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수)
|
||||
- **customer_name**: 고객명/가게명 (필수)
|
||||
|
|
@ -195,18 +190,16 @@ async def get_lyric_by_task_id(
|
|||
- GET /lyric/status/{task_id} 로 처리 상태 확인
|
||||
- GET /lyric/{task_id} 로 생성된 가사 조회
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/lyric/generate" \\
|
||||
-H "Authorization: Bearer {access_token}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
## 사용 예시
|
||||
```
|
||||
POST /lyric/generate
|
||||
{
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean"
|
||||
}'
|
||||
}
|
||||
```
|
||||
|
||||
## 응답 예시
|
||||
|
|
@ -223,14 +216,12 @@ curl -X POST "http://localhost:8000/lyric/generate" \\
|
|||
response_model=GenerateLyricResponse,
|
||||
responses={
|
||||
200: {"description": "가사 생성 요청 접수 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
500: {"description": "서버 내부 오류"},
|
||||
},
|
||||
)
|
||||
async def generate_lyric(
|
||||
request_body: GenerateLyricRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> GenerateLyricResponse:
|
||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||
|
|
@ -302,7 +293,6 @@ 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()
|
||||
|
|
@ -383,30 +373,24 @@ async def generate_lyric(
|
|||
description="""
|
||||
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 상태 값
|
||||
- **processing**: 가사 생성 중
|
||||
- **completed**: 가사 생성 완료
|
||||
- **failed**: 가사 생성 실패
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/lyric/status/019123ab-cdef-7890-abcd-ef1234567890" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
|
||||
```
|
||||
""",
|
||||
response_model=LyricStatusResponse,
|
||||
responses={
|
||||
200: {"description": "상태 조회 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_lyric_status(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LyricStatusResponse:
|
||||
"""task_id로 가사 생성 작업 상태를 조회합니다."""
|
||||
|
|
@ -414,14 +398,11 @@ async def get_lyric_status(
|
|||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
"s/",
|
||||
summary="가사 목록 조회 (페이지네이션)",
|
||||
description="""
|
||||
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 파라미터
|
||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||
- **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100)
|
||||
|
|
@ -435,19 +416,11 @@ async def get_lyric_status(
|
|||
- **has_next**: 다음 페이지 존재 여부
|
||||
- **has_prev**: 이전 페이지 존재 여부
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
# 기본 조회 (1페이지, 20개)
|
||||
curl -X GET "http://localhost:8000/lyric/list" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
|
||||
# 2페이지 조회
|
||||
curl -X GET "http://localhost:8000/lyric/list?page=2" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
|
||||
# 50개씩 조회
|
||||
curl -X GET "http://localhost:8000/lyric/list?page=1&page_size=50" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyrics/ # 기본 조회 (1페이지, 20개)
|
||||
GET /lyrics/?page=2 # 2페이지 조회
|
||||
GET /lyrics/?page=1&page_size=50 # 50개씩 조회
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
|
@ -457,13 +430,11 @@ curl -X GET "http://localhost:8000/lyric/list?page=1&page_size=50" \\
|
|||
response_model=PaginatedResponse[LyricListItem],
|
||||
responses={
|
||||
200: {"description": "가사 목록 조회 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
},
|
||||
)
|
||||
async def list_lyrics(
|
||||
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PaginatedResponse[LyricListItem]:
|
||||
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
||||
|
|
@ -485,9 +456,6 @@ async def list_lyrics(
|
|||
description="""
|
||||
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 반환 정보
|
||||
- **id**: 가사 ID
|
||||
- **task_id**: 작업 고유 식별자
|
||||
|
|
@ -497,22 +465,19 @@ task_id로 생성된 가사의 상세 정보를 조회합니다.
|
|||
- **lyric_result**: 생성된 가사 (완료 시)
|
||||
- **created_at**: 생성 일시
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/lyric/019123ab-cdef-7890-abcd-ef1234567890" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
## 사용 예시
|
||||
```
|
||||
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
|
||||
```
|
||||
""",
|
||||
response_model=LyricDetailResponse,
|
||||
responses={
|
||||
200: {"description": "가사 조회 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
404: {"description": "해당 task_id를 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_lyric_detail(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LyricDetailResponse:
|
||||
"""task_id로 생성된 가사를 조회합니다."""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ class Lyric(Base):
|
|||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
project_id: 연결된 Project의 id (외래키)
|
||||
task_id: 가사 생성 작업의 고유 식별자 (UUID7 형식)
|
||||
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
|
||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||
lyric_prompt: 가사 생성에 사용된 프롬프트
|
||||
lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원)
|
||||
|
|
@ -37,9 +37,6 @@ class Lyric(Base):
|
|||
|
||||
__tablename__ = "lyric"
|
||||
__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",
|
||||
|
|
@ -59,13 +56,14 @@ class Lyric(Base):
|
|||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="가사 생성 작업 고유 식별자 (UUID7)",
|
||||
comment="가사 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
|
|
@ -93,13 +91,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Song API Router
|
|||
|
||||
사용 예시:
|
||||
from app.song.api.routers.v1.song import router
|
||||
app.include_router(router)
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
|
|
@ -18,8 +18,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.database.session import get_session
|
||||
from app.home.models import Project
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song, SongTimestamp
|
||||
from app.song.schemas.song_schema import (
|
||||
|
|
@ -42,9 +40,6 @@ router = APIRouter(prefix="/song", tags=["Song"])
|
|||
description="""
|
||||
Suno API를 통해 노래 생성을 요청합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 데 사용
|
||||
|
||||
|
|
@ -59,16 +54,14 @@ Suno API를 통해 노래 생성을 요청합니다.
|
|||
- **song_id**: Suno API 작업 ID (상태 조회에 사용)
|
||||
- **message**: 응답 메시지
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234567890" \\
|
||||
-H "Authorization: Bearer {access_token}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
## 사용 예시
|
||||
```
|
||||
POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
|
||||
{
|
||||
"lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께",
|
||||
"genre": "K-Pop",
|
||||
"language": "Korean"
|
||||
}'
|
||||
}
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
|
@ -79,7 +72,6 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
|
|||
response_model=GenerateSongResponse,
|
||||
responses={
|
||||
200: {"description": "노래 생성 요청 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
404: {"description": "Project 또는 Lyric을 찾을 수 없음"},
|
||||
500: {"description": "노래 생성 요청 실패"},
|
||||
},
|
||||
|
|
@ -87,7 +79,6 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
|
|||
async def generate_song(
|
||||
task_id: str,
|
||||
request_body: GenerateSongRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> GenerateSongResponse:
|
||||
"""가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다.
|
||||
|
||||
|
|
@ -322,9 +313,6 @@ async def generate_song(
|
|||
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **song_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
|
||||
|
||||
|
|
@ -333,10 +321,9 @@ SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
|||
- **status**: Suno API 작업 상태
|
||||
- **message**: 상태 메시지
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/song/status/{song_id}" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
## 사용 예시
|
||||
```
|
||||
GET /song/status/abc123...
|
||||
```
|
||||
|
||||
## 상태 값 (Suno API 응답)
|
||||
|
|
@ -355,13 +342,11 @@ curl -X GET "http://localhost:8000/song/status/{song_id}" \\
|
|||
response_model=PollingSongResponse,
|
||||
responses={
|
||||
200: {"description": "상태 조회 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
},
|
||||
)
|
||||
async def get_song_status(
|
||||
song_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PollingSongResponse:
|
||||
"""song_id로 노래 생성 작업의 상태를 조회합니다.
|
||||
|
|
@ -436,7 +421,6 @@ 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(
|
||||
|
|
@ -483,19 +467,6 @@ 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 Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
|
@ -23,7 +23,7 @@ class Song(Base):
|
|||
id: 고유 식별자 (자동 증가)
|
||||
project_id: 연결된 Project의 id (외래키)
|
||||
lyric_id: 연결된 Lyric의 id (외래키)
|
||||
task_id: 노래 생성 작업의 고유 식별자 (UUID7 형식)
|
||||
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
|
||||
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
||||
status: 처리 상태 (processing, uploading, completed, failed)
|
||||
song_prompt: 노래 생성에 사용된 프롬프트
|
||||
|
|
@ -39,10 +39,6 @@ class Song(Base):
|
|||
|
||||
__tablename__ = "song"
|
||||
__table_args__ = (
|
||||
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",
|
||||
|
|
@ -62,6 +58,7 @@ class Song(Base):
|
|||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
|
|
@ -69,13 +66,14 @@ class Song(Base):
|
|||
Integer,
|
||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Lyric의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="노래 생성 작업 고유 식별자 (UUID7)",
|
||||
comment="노래 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
suno_task_id: Mapped[Optional[str]] = mapped_column(
|
||||
|
|
@ -120,13 +118,6 @@ 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,
|
||||
|
|
@ -186,8 +177,6 @@ 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",
|
||||
|
|
@ -206,6 +195,7 @@ class SongTimestamp(Base):
|
|||
suno_audio_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="가사의 원본 오디오 ID",
|
||||
)
|
||||
|
||||
|
|
@ -233,13 +223,6 @@ 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,7 +177,6 @@ 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 테이블을 업데이트합니다.
|
||||
|
|
@ -186,7 +185,6 @@ 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}")
|
||||
|
|
@ -235,7 +233,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(user_uuid=user_uuid, task_id=task_id)
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
|
||||
|
||||
if not upload_success:
|
||||
|
|
|
|||
|
|
@ -5,14 +5,10 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, Header, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import prj_settings
|
||||
|
|
@ -20,7 +16,7 @@ from app.database.session import get_session
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import RefreshToken, User
|
||||
from app.user.models import User
|
||||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoCodeRequest,
|
||||
|
|
@ -30,56 +26,9 @@ from app.user.schemas.user_schema import (
|
|||
UserResponse,
|
||||
)
|
||||
from app.user.services import auth_service, kakao_client
|
||||
from app.user.services.jwt import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_access_token_expire_seconds,
|
||||
get_refresh_token_expires_at,
|
||||
get_token_hash,
|
||||
)
|
||||
from app.utils.common import generate_uuid
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
|
||||
# =============================================================================
|
||||
# 테스트용 라우터 (DEBUG 모드에서만 main.py에서 등록됨)
|
||||
# =============================================================================
|
||||
test_router = APIRouter(prefix="/auth/test", tags=["Test Auth"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 테스트용 스키마
|
||||
# =============================================================================
|
||||
class TestUserCreateRequest(BaseModel):
|
||||
"""테스트 사용자 생성 요청"""
|
||||
|
||||
nickname: str = "테스트유저"
|
||||
|
||||
|
||||
class TestUserCreateResponse(BaseModel):
|
||||
"""테스트 사용자 생성 응답"""
|
||||
|
||||
user_id: int
|
||||
user_uuid: str
|
||||
nickname: str
|
||||
message: str
|
||||
|
||||
|
||||
class TestTokenRequest(BaseModel):
|
||||
"""테스트 토큰 발급 요청"""
|
||||
|
||||
user_uuid: str
|
||||
|
||||
|
||||
class TestTokenResponse(BaseModel):
|
||||
"""테스트 토큰 발급 응답"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.get(
|
||||
"/kakao/login",
|
||||
|
|
@ -238,10 +187,6 @@ async def refresh_token(
|
|||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="로그아웃",
|
||||
description="현재 세션의 리프레시 토큰을 폐기합니다.",
|
||||
responses={
|
||||
204: {"description": "로그아웃 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
},
|
||||
)
|
||||
async def logout(
|
||||
body: RefreshTokenRequest,
|
||||
|
|
@ -267,10 +212,6 @@ async def logout(
|
|||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="모든 기기에서 로그아웃",
|
||||
description="사용자의 모든 리프레시 토큰을 폐기합니다.",
|
||||
responses={
|
||||
204: {"description": "모든 기기에서 로그아웃 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
},
|
||||
)
|
||||
async def logout_all(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
|
@ -294,10 +235,6 @@ async def logout_all(
|
|||
response_model=UserResponse,
|
||||
summary="내 정보 조회",
|
||||
description="현재 로그인한 사용자의 정보를 반환합니다.",
|
||||
responses={
|
||||
200: {"description": "조회 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
},
|
||||
)
|
||||
async def get_me(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
|
@ -308,143 +245,3 @@ async def get_me(
|
|||
현재 로그인한 사용자의 상세 정보를 반환합니다.
|
||||
"""
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 테스트용 엔드포인트 (DEBUG 모드에서만 main.py에서 라우터가 등록됨)
|
||||
# =============================================================================
|
||||
@test_router.post(
|
||||
"/create-user",
|
||||
response_model=TestUserCreateResponse,
|
||||
summary="[테스트] 사용자 직접 생성",
|
||||
description="""
|
||||
**DEBUG 모드에서만 사용 가능합니다.**
|
||||
|
||||
카카오 로그인 없이 테스트용 사용자를 직접 생성합니다.
|
||||
생성된 user_uuid로 `/generate-token` 엔드포인트에서 토큰을 발급받을 수 있습니다.
|
||||
""",
|
||||
)
|
||||
async def create_test_user(
|
||||
body: TestUserCreateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TestUserCreateResponse:
|
||||
"""
|
||||
테스트용 사용자 직접 생성
|
||||
|
||||
카카오 로그인 없이 테스트용 사용자를 생성합니다.
|
||||
DEBUG 모드에서만 사용 가능합니다.
|
||||
"""
|
||||
logger.info(f"[TEST] 테스트 사용자 생성 요청 - nickname: {body.nickname}")
|
||||
|
||||
# 고유한 uuid 생성
|
||||
user_uuid = await generate_uuid(session=session, table_name=User)
|
||||
|
||||
# 테스트용 가짜 kakao_id 생성 (충돌 방지를 위해 큰 범위 사용)
|
||||
fake_kakao_id = random.randint(9000000000, 9999999999)
|
||||
|
||||
# 사용자 생성
|
||||
new_user = User(
|
||||
kakao_id=fake_kakao_id,
|
||||
user_uuid=user_uuid,
|
||||
nickname=body.nickname,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(new_user)
|
||||
await session.commit()
|
||||
await session.refresh(new_user)
|
||||
|
||||
logger.info(
|
||||
f"[TEST] 테스트 사용자 생성 완료 - user_id: {new_user.id}, "
|
||||
f"user_uuid: {new_user.user_uuid}"
|
||||
)
|
||||
|
||||
return TestUserCreateResponse(
|
||||
user_id=new_user.id,
|
||||
user_uuid=new_user.user_uuid,
|
||||
nickname=new_user.nickname or body.nickname,
|
||||
message="테스트 사용자가 생성되었습니다.",
|
||||
)
|
||||
|
||||
|
||||
@test_router.post(
|
||||
"/generate-token",
|
||||
response_model=TestTokenResponse,
|
||||
summary="[테스트] 토큰 직접 발급",
|
||||
description="""
|
||||
**DEBUG 모드에서만 사용 가능합니다.**
|
||||
|
||||
user_uuid로 JWT 토큰을 직접 발급합니다.
|
||||
`/create-user`에서 생성한 사용자의 user_uuid를 사용하세요.
|
||||
""",
|
||||
)
|
||||
async def generate_test_token(
|
||||
request: Request,
|
||||
body: TestTokenRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
||||
) -> TestTokenResponse:
|
||||
"""
|
||||
테스트용 토큰 직접 발급
|
||||
|
||||
카카오 로그인 없이 user_uuid로 JWT 토큰을 발급합니다.
|
||||
DEBUG 모드에서만 사용 가능합니다.
|
||||
"""
|
||||
logger.info(f"[TEST] 테스트 토큰 발급 요청 - user_uuid: {body.user_uuid}")
|
||||
|
||||
# 사용자 조회
|
||||
result = await session.execute(
|
||||
select(User).where(User.user_uuid == body.user_uuid)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"사용자를 찾을 수 없습니다: {body.user_uuid}",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="비활성화된 사용자입니다.",
|
||||
)
|
||||
|
||||
# JWT 토큰 생성
|
||||
access_token = create_access_token(user.user_uuid)
|
||||
refresh_token = create_refresh_token(user.user_uuid)
|
||||
|
||||
# 클라이언트 IP 추출
|
||||
ip_address = request.client.host if request.client else None
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
ip_address = forwarded_for.split(",")[0].strip()
|
||||
|
||||
# 리프레시 토큰 DB 저장
|
||||
token_hash = get_token_hash(refresh_token)
|
||||
expires_at = get_refresh_token_expires_at()
|
||||
|
||||
db_refresh_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
user_uuid=user.user_uuid,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
session.add(db_refresh_token)
|
||||
|
||||
# 마지막 로그인 시간 업데이트
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[TEST] 테스트 토큰 발급 완료 - user_id: {user.id}, "
|
||||
f"user_uuid: {user.user_uuid}"
|
||||
)
|
||||
|
||||
return TestTokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="Bearer",
|
||||
expires_in=get_access_token_expire_seconds(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class UserAdmin(ModelView, model=User):
|
|||
form_excluded_columns = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"projects",
|
||||
"user_projects",
|
||||
"refresh_tokens",
|
||||
"social_accounts",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -58,14 +58,14 @@ async def get_current_user(
|
|||
if payload.get("type") != "access":
|
||||
raise InvalidTokenError("액세스 토큰이 아닙니다.")
|
||||
|
||||
user_uuid = payload.get("sub")
|
||||
if user_uuid is None:
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# 사용자 조회
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.user_uuid == user_uuid,
|
||||
User.id == int(user_id),
|
||||
User.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
|
|
@ -106,13 +106,13 @@ async def get_current_user_optional(
|
|||
if payload.get("type") != "access":
|
||||
return None
|
||||
|
||||
user_uuid = payload.get("sub")
|
||||
if user_uuid is None:
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.user_uuid == user_uuid,
|
||||
User.id == int(user_id),
|
||||
User.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ class MissingTokenError(AuthException):
|
|||
class UserNotFoundError(AuthException):
|
||||
"""사용자 없음"""
|
||||
|
||||
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
|
||||
def __init__(self, message: str = "사용자를 찾을 수 없습니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="USER_NOT_FOUND",
|
||||
|
|
@ -122,7 +122,7 @@ class UserNotFoundError(AuthException):
|
|||
class UserInactiveError(AuthException):
|
||||
"""비활성화된 계정"""
|
||||
|
||||
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
|
||||
def __init__(self, message: str = "비활성화된 계정입니다. 관리자에게 문의하세요."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
code="USER_INACTIVE",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.home.models import Project
|
||||
from app.home.models import UserProject
|
||||
|
||||
|
||||
class User(Base):
|
||||
|
|
@ -26,7 +26,6 @@ class User(Base):
|
|||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
kakao_id: 카카오 고유 ID (필수, 유니크)
|
||||
user_uuid: 사용자 식별을 위한 UUID7 (필수, 유니크)
|
||||
email: 이메일 주소 (선택, 카카오에서 제공 시)
|
||||
nickname: 카카오 닉네임 (선택)
|
||||
profile_image_url: 카카오 프로필 이미지 URL (선택)
|
||||
|
|
@ -54,7 +53,8 @@ class User(Base):
|
|||
- 실제 데이터는 DB에 유지됨
|
||||
|
||||
Relationships:
|
||||
projects: 사용자가 소유한 프로젝트 목록 (1:N 관계)
|
||||
user_projects: Project와의 M:N 관계 (중계 테이블 통한 연결)
|
||||
projects: 사용자가 참여한 프로젝트 목록 (Association Proxy)
|
||||
|
||||
카카오 API 응답 필드 매핑:
|
||||
- kakao_id: id (카카오 회원번호)
|
||||
|
|
@ -68,8 +68,7 @@ class User(Base):
|
|||
|
||||
__tablename__ = "user"
|
||||
__table_args__ = (
|
||||
Index("idx_user_kakao_id", "kakao_id"),
|
||||
Index("idx_user_uuid", "user_uuid"),
|
||||
Index("idx_user_kakao_id", "kakao_id", unique=True),
|
||||
Index("idx_user_email", "email"),
|
||||
Index("idx_user_phone", "phone"),
|
||||
Index("idx_user_is_active", "is_active"),
|
||||
|
|
@ -104,13 +103,6 @@ class User(Base):
|
|||
comment="카카오 고유 ID (회원번호)",
|
||||
)
|
||||
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="사용자 식별을 위한 UUID7 (시간순 정렬 가능한 UUID)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 카카오에서 제공하는 사용자 정보 (선택적)
|
||||
# ==========================================================================
|
||||
|
|
@ -230,15 +222,16 @@ class User(Base):
|
|||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Project 1:N 관계 (한 사용자가 여러 프로젝트를 소유)
|
||||
# Project M:N 관계 (중계 테이블 UserProject 통한 연결)
|
||||
# ==========================================================================
|
||||
# back_populates: Project.owner와 양방향 연결
|
||||
# cascade: 사용자 삭제 시 프로젝트는 유지 (owner가 NULL로 설정됨)
|
||||
# back_populates: UserProject.user와 양방향 연결
|
||||
# cascade: User 삭제 시 UserProject 레코드도 삭제 (Project는 유지)
|
||||
# lazy="selectin": N+1 문제 방지
|
||||
# ==========================================================================
|
||||
projects: Mapped[List["Project"]] = relationship(
|
||||
"Project",
|
||||
back_populates="owner",
|
||||
user_projects: Mapped[List["UserProject"]] = relationship(
|
||||
"UserProject",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
|
@ -289,7 +282,6 @@ class RefreshToken(Base):
|
|||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
user_id: 사용자 외래키 (User.id 참조)
|
||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||
token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X)
|
||||
expires_at: 토큰 만료 일시
|
||||
is_revoked: 토큰 폐기 여부 (로그아웃 시 True)
|
||||
|
|
@ -307,7 +299,6 @@ class RefreshToken(Base):
|
|||
__tablename__ = "refresh_token"
|
||||
__table_args__ = (
|
||||
Index("idx_refresh_token_user_id", "user_id"),
|
||||
Index("idx_refresh_token_user_uuid", "user_uuid"),
|
||||
Index("idx_refresh_token_token_hash", "token_hash", unique=True),
|
||||
Index("idx_refresh_token_expires_at", "expires_at"),
|
||||
Index("idx_refresh_token_is_revoked", "is_revoked"),
|
||||
|
|
@ -333,12 +324,6 @@ class RefreshToken(Base):
|
|||
comment="사용자 외래키 (User.id 참조)",
|
||||
)
|
||||
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="사용자 UUID (User.user_uuid 참조)",
|
||||
)
|
||||
|
||||
token_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
|
|
@ -436,13 +421,12 @@ class SocialAccount(Base):
|
|||
|
||||
__tablename__ = "social_account"
|
||||
__table_args__ = (
|
||||
Index("idx_social_account_user_uuid", "user_uuid"),
|
||||
Index("idx_social_account_user_id", "user_id"),
|
||||
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",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_user_id",
|
||||
unique=True,
|
||||
|
|
@ -465,11 +449,11 @@ class SocialAccount(Base):
|
|||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 외래키 (User.user_uuid 참조)",
|
||||
comment="사용자 외래키 (User.id 참조)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
|
|
@ -542,13 +526,6 @@ class SocialAccount(Base):
|
|||
comment="연동 활성화 상태 (비활성화 시 사용 중지)",
|
||||
)
|
||||
|
||||
is_deleted: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="소프트 삭제 여부 (True: 삭제됨)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 시간 정보
|
||||
# ==========================================================================
|
||||
|
|
@ -579,7 +556,7 @@ class SocialAccount(Base):
|
|||
return (
|
||||
f"<SocialAccount("
|
||||
f"id={self.id}, "
|
||||
f"user_uuid='{self.user_uuid}', "
|
||||
f"user_id={self.user_id}, "
|
||||
f"platform='{self.platform}', "
|
||||
f"platform_username='{self.platform_username}', "
|
||||
f"is_active={self.is_active}"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class KakaoLoginResponse(BaseModel):
|
|||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/user/auth/kakao/callback&response_type=code"
|
||||
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/api/v1/user/auth/kakao/callback&response_type=code"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,13 +156,13 @@ class UserBriefResponse(BaseModel):
|
|||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""로그인 응답 (토큰 정보)"""
|
||||
"""로그인 응답 (토큰 + 사용자 정보)"""
|
||||
|
||||
access_token: str = Field(..., description="액세스 토큰")
|
||||
refresh_token: str = Field(..., description="리프레시 토큰")
|
||||
token_type: str = Field(default="Bearer", description="토큰 타입")
|
||||
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
|
||||
is_new_user: bool = Field(..., description="신규 가입 여부")
|
||||
user: UserBriefResponse = Field(..., description="사용자 정보")
|
||||
redirect_url: str = Field(..., description="로그인 후 리다이렉트할 프론트엔드 URL")
|
||||
|
||||
model_config = {
|
||||
|
|
@ -172,7 +172,13 @@ class LoginResponse(BaseModel):
|
|||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"is_new_user": False,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"nickname": "홍길동",
|
||||
"email": "user@kakao.com",
|
||||
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
|
||||
"is_new_user": False
|
||||
},
|
||||
"redirect_url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,11 +24,11 @@ from app.user.exceptions import (
|
|||
UserNotFoundError,
|
||||
)
|
||||
from app.user.models import RefreshToken, User
|
||||
from app.utils.common import generate_uuid
|
||||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoUserInfo,
|
||||
LoginResponse,
|
||||
UserBriefResponse,
|
||||
)
|
||||
from app.user.services.jwt import (
|
||||
create_access_token,
|
||||
|
|
@ -96,28 +96,27 @@ class AuthService:
|
|||
|
||||
# 5. JWT 토큰 생성
|
||||
logger.info("[AUTH] 5단계: JWT 토큰 생성 시작")
|
||||
access_token = create_access_token(user.user_uuid)
|
||||
refresh_token = create_refresh_token(user.user_uuid)
|
||||
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_uuid: {user.user_uuid}")
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_id: {user.id}")
|
||||
|
||||
# 6. 리프레시 토큰 DB 저장
|
||||
logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작")
|
||||
await self._save_refresh_token(
|
||||
user_id=user.id,
|
||||
user_uuid=user.user_uuid,
|
||||
token=refresh_token,
|
||||
session=session,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
|
||||
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}")
|
||||
|
||||
# 7. 마지막 로그인 시간 업데이트
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
|
||||
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}, redirect_url: {redirect_url}")
|
||||
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, redirect_url: {redirect_url}")
|
||||
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
|
||||
|
||||
return LoginResponse(
|
||||
|
|
@ -125,7 +124,13 @@ class AuthService:
|
|||
refresh_token=refresh_token,
|
||||
token_type="Bearer",
|
||||
expires_in=get_access_token_expire_seconds(),
|
||||
user=UserBriefResponse(
|
||||
id=user.id,
|
||||
nickname=user.nickname,
|
||||
email=user.email,
|
||||
profile_image_url=user.profile_image_url,
|
||||
is_new_user=is_new_user,
|
||||
),
|
||||
redirect_url=redirect_url,
|
||||
)
|
||||
|
||||
|
|
@ -172,8 +177,8 @@ class AuthService:
|
|||
raise TokenExpiredError()
|
||||
|
||||
# 4. 사용자 확인
|
||||
user_uuid = payload.get("sub")
|
||||
user = await self._get_user_by_uuid(user_uuid, session)
|
||||
user_id = int(payload.get("sub"))
|
||||
user = await self._get_user_by_id(user_id, session)
|
||||
|
||||
if user is None:
|
||||
raise UserNotFoundError()
|
||||
|
|
@ -182,7 +187,7 @@ class AuthService:
|
|||
raise UserInactiveError()
|
||||
|
||||
# 5. 새 액세스 토큰 발급
|
||||
new_access_token = create_access_token(user.user_uuid)
|
||||
new_access_token = create_access_token(user.id)
|
||||
|
||||
return AccessTokenResponse(
|
||||
access_token=new_access_token,
|
||||
|
|
@ -264,10 +269,8 @@ class AuthService:
|
|||
|
||||
# 신규 사용자 생성
|
||||
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
|
||||
user_uuid = await generate_uuid(session=session, table_name=User)
|
||||
new_user = User(
|
||||
kakao_id=kakao_id,
|
||||
user_uuid=user_uuid,
|
||||
email=kakao_account.email if kakao_account else None,
|
||||
nickname=profile.nickname if profile else None,
|
||||
profile_image_url=profile.profile_image_url if profile else None,
|
||||
|
|
@ -316,7 +319,6 @@ class AuthService:
|
|||
async def _save_refresh_token(
|
||||
self,
|
||||
user_id: int,
|
||||
user_uuid: str,
|
||||
token: str,
|
||||
session: AsyncSession,
|
||||
user_agent: Optional[str] = None,
|
||||
|
|
@ -327,7 +329,6 @@ class AuthService:
|
|||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
user_uuid: 사용자 UUID
|
||||
token: 리프레시 토큰
|
||||
session: DB 세션
|
||||
user_agent: User-Agent
|
||||
|
|
@ -341,7 +342,6 @@ class AuthService:
|
|||
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user_id,
|
||||
user_uuid=user_uuid,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
user_agent=user_agent,
|
||||
|
|
@ -391,26 +391,6 @@ class AuthService:
|
|||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_by_uuid(
|
||||
self,
|
||||
user_uuid: str,
|
||||
session: AsyncSession,
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
UUID로 사용자 조회
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
User 객체 또는 None
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.user_uuid == user_uuid, User.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _revoke_refresh_token_by_hash(
|
||||
self,
|
||||
token_hash: str,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ from jose import JWTError, jwt
|
|||
from config import jwt_settings
|
||||
|
||||
|
||||
def create_access_token(user_uuid: str) -> str:
|
||||
def create_access_token(user_id: int) -> str:
|
||||
"""
|
||||
JWT 액세스 토큰 생성
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
JWT 액세스 토큰 문자열
|
||||
|
|
@ -27,7 +27,7 @@ def create_access_token(user_uuid: str) -> str:
|
|||
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode = {
|
||||
"sub": user_uuid,
|
||||
"sub": str(user_id),
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
|
|
@ -38,12 +38,12 @@ def create_access_token(user_uuid: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
def create_refresh_token(user_uuid: str) -> str:
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
"""
|
||||
JWT 리프레시 토큰 생성
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
JWT 리프레시 토큰 문자열
|
||||
|
|
@ -52,7 +52,7 @@ def create_refresh_token(user_uuid: str) -> str:
|
|||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
to_encode = {
|
||||
"sub": user_uuid,
|
||||
"sub": str(user_id),
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,71 +4,21 @@ Common Utility Functions
|
|||
공통으로 사용되는 유틸리티 함수들을 정의합니다.
|
||||
|
||||
사용 예시:
|
||||
from app.utils.common import generate_task_id, generate_uuid
|
||||
from app.utils.common import generate_task_id
|
||||
|
||||
# task_id 생성
|
||||
task_id = await generate_task_id(session=session, table_name=Project)
|
||||
|
||||
# uuid 생성
|
||||
user_uuid = await generate_uuid(session=session, table_name=User)
|
||||
|
||||
Note:
|
||||
페이지네이션 기능은 app.utils.pagination 모듈을 사용하세요:
|
||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
def _generate_uuid7_string() -> str:
|
||||
"""UUID7 문자열을 생성합니다.
|
||||
|
||||
UUID7 구조 (RFC 9562):
|
||||
- 48 bits: Unix timestamp (밀리초)
|
||||
- 4 bits: 버전 (7)
|
||||
- 12 bits: 랜덤
|
||||
- 2 bits: variant (10)
|
||||
- 62 bits: 랜덤
|
||||
- 총 128 bits -> 36자 (하이픈 포함)
|
||||
|
||||
Returns:
|
||||
36자리 UUID7 문자열 (xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx)
|
||||
"""
|
||||
# 현재 시간 (밀리초)
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
|
||||
# 랜덤 바이트 (10바이트 = 80비트)
|
||||
random_bytes = os.urandom(10)
|
||||
|
||||
# UUID7 바이트 구성 (16바이트 = 128비트)
|
||||
# 처음 6바이트: 타임스탬프 (48비트)
|
||||
uuid_bytes = timestamp_ms.to_bytes(6, byteorder="big")
|
||||
|
||||
# 다음 2바이트: 버전(7) + 랜덤 12비트
|
||||
# 0x7000 | (random 12 bits)
|
||||
rand_a = int.from_bytes(random_bytes[0:2], byteorder="big")
|
||||
version_rand = (0x7000 | (rand_a & 0x0FFF)).to_bytes(2, byteorder="big")
|
||||
uuid_bytes += version_rand
|
||||
|
||||
# 다음 2바이트: variant(10) + 랜덤 62비트의 앞 6비트
|
||||
# 0x80 | (random 6 bits) + random 8 bits
|
||||
rand_b = random_bytes[2]
|
||||
variant_rand = bytes([0x80 | (rand_b & 0x3F)]) + random_bytes[3:4]
|
||||
uuid_bytes += variant_rand
|
||||
|
||||
# 나머지 6바이트: 랜덤
|
||||
uuid_bytes += random_bytes[4:10]
|
||||
|
||||
# 16진수로 변환
|
||||
hex_str = uuid_bytes.hex()
|
||||
|
||||
# UUID 형식으로 포맷팅 (8-4-4-4-12)
|
||||
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
||||
from uuid_extensions import uuid7
|
||||
|
||||
|
||||
async def generate_task_id(
|
||||
|
|
@ -82,16 +32,16 @@ async def generate_task_id(
|
|||
table_name: task_id 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
|
||||
|
||||
Returns:
|
||||
str: 생성된 UUID7 문자열 (36자)
|
||||
str: 생성된 uuid7 문자열
|
||||
|
||||
Usage:
|
||||
# 단순 UUID7 생성
|
||||
# 단순 uuid7 생성
|
||||
task_id = await generate_task_id()
|
||||
|
||||
# 테이블에서 중복 검사 후 생성
|
||||
task_id = await generate_task_id(session=session, table_name=Project)
|
||||
"""
|
||||
task_id = _generate_uuid7_string()
|
||||
task_id = str(uuid7())
|
||||
|
||||
if session is None or table_name is None:
|
||||
return task_id
|
||||
|
|
@ -105,41 +55,4 @@ async def generate_task_id(
|
|||
if existing is None:
|
||||
return task_id
|
||||
|
||||
task_id = _generate_uuid7_string()
|
||||
|
||||
|
||||
async def generate_uuid(
|
||||
session: Optional[AsyncSession] = None,
|
||||
table_name: Optional[Type[Any]] = None,
|
||||
) -> str:
|
||||
"""고유한 UUID7을 생성합니다.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy AsyncSession (optional)
|
||||
table_name: user_uuid 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
|
||||
|
||||
Returns:
|
||||
str: 생성된 UUID7 문자열 (36자)
|
||||
|
||||
Usage:
|
||||
# 단순 UUID7 생성
|
||||
new_uuid = await generate_uuid()
|
||||
|
||||
# 테이블에서 중복 검사 후 생성
|
||||
new_uuid = await generate_uuid(session=session, table_name=User)
|
||||
"""
|
||||
new_uuid = _generate_uuid7_string()
|
||||
|
||||
if session is None or table_name is None:
|
||||
return new_uuid
|
||||
|
||||
while True:
|
||||
result = await session.execute(
|
||||
select(table_name).where(table_name.user_uuid == new_uuid)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
return new_uuid
|
||||
|
||||
new_uuid = _generate_uuid7_string()
|
||||
task_id = str(uuid7())
|
||||
|
|
|
|||
|
|
@ -488,8 +488,7 @@ class CreatomateService:
|
|||
json=payload,
|
||||
)
|
||||
|
||||
# 200 OK, 201 Created, 202 Accepted 모두 성공으로 처리
|
||||
if response.status_code in (200, 201, 202):
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
return response.json()
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
|
|
@ -558,8 +557,7 @@ class CreatomateService:
|
|||
json=source,
|
||||
)
|
||||
|
||||
# 200 OK, 201 Created, 202 Accepted 모두 성공으로 처리
|
||||
if response.status_code in (200, 201, 202):
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
return response.json()
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ from typing import List
|
|||
|
||||
# Input 정의
|
||||
class MarketingPromptInput(BaseModel):
|
||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||
region : str = Field(..., description = "마케팅 대상 지역")
|
||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
customer_name : str
|
||||
region : str
|
||||
detail_region_info : str
|
||||
|
||||
|
||||
# Output 정의
|
||||
|
|
@ -25,7 +25,7 @@ class AgeRange(BaseModel):
|
|||
|
||||
class TargetPersona(BaseModel):
|
||||
persona: str = Field(..., description="타겟 페르소나 이름/설명")
|
||||
age: AgeRange = Field(..., description="타겟 페르소나 나이대")
|
||||
age: AgeRange
|
||||
favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
|
||||
decision_trigger: str = Field(..., description="구매 결정 트리거")
|
||||
|
||||
|
|
@ -36,8 +36,8 @@ class SellingPoint(BaseModel):
|
|||
score: int = Field(..., ge=0, le=100, description="점수 (100점 만점)")
|
||||
|
||||
class MarketingPromptOutput(BaseModel):
|
||||
brand_identity: BrandIdentity = Field(..., description="브랜드 아이덴티티")
|
||||
market_positioning: MarketPositioning = Field(..., description="시장 포지셔닝")
|
||||
target_persona: List[TargetPersona] = Field(..., description="타겟 페르소나")
|
||||
selling_points: List[SellingPoint] = Field(..., description="셀링 포인트")
|
||||
brand_identity: BrandIdentity
|
||||
market_positioning: MarketPositioning
|
||||
target_persona: List[TargetPersona]
|
||||
selling_points: List[SellingPoint]
|
||||
target_keywords: List[str] = Field(..., description="타겟 키워드 리스트")
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
|
|||
파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다.
|
||||
|
||||
URL 경로 형식:
|
||||
- 음악: {BASE_URL}/{user_uuid}/{task_id}/song/{파일명}
|
||||
- 영상: {BASE_URL}/{user_uuid}/{task_id}/video/{파일명}
|
||||
- 이미지: {BASE_URL}/{user_uuid}/{task_id}/image/{파일명}
|
||||
- 음악: {BASE_URL}/{task_id}/song/{파일명}
|
||||
- 영상: {BASE_URL}/{task_id}/video/{파일명}
|
||||
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
|
||||
|
||||
사용 예시:
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
|
||||
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
|
||||
# 파일 경로로 업로드
|
||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
||||
|
|
@ -79,15 +79,14 @@ class AzureBlobUploader:
|
|||
"""Azure Blob Storage 업로드 클래스
|
||||
|
||||
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
|
||||
URL 형식: {BASE_URL}/{user_uuid}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
|
||||
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
|
||||
|
||||
카테고리별 경로:
|
||||
- 음악: {user_uuid}/{task_id}/song/{file_name}
|
||||
- 영상: {user_uuid}/{task_id}/video/{file_name}
|
||||
- 이미지: {user_uuid}/{task_id}/image/{file_name}
|
||||
- 음악: {task_id}/song/{file_name}
|
||||
- 영상: {task_id}/video/{file_name}
|
||||
- 이미지: {task_id}/image/{file_name}
|
||||
|
||||
Attributes:
|
||||
user_uuid: 사용자 고유 식별자 (UUID)
|
||||
task_id: 작업 고유 식별자
|
||||
"""
|
||||
|
||||
|
|
@ -101,24 +100,17 @@ class AzureBlobUploader:
|
|||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
def __init__(self, user_uuid: str, task_id: str):
|
||||
def __init__(self, task_id: str):
|
||||
"""AzureBlobUploader 초기화
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 고유 식별자 (UUID)
|
||||
task_id: 작업 고유 식별자
|
||||
"""
|
||||
self._user_uuid = user_uuid
|
||||
self._task_id = task_id
|
||||
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
|
||||
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
||||
self._last_public_url: str = ""
|
||||
|
||||
@property
|
||||
def user_uuid(self) -> str:
|
||||
"""사용자 고유 식별자 (UUID)"""
|
||||
return self._user_uuid
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
"""작업 고유 식별자"""
|
||||
|
|
@ -134,12 +126,12 @@ class AzureBlobUploader:
|
|||
# SAS 토큰 앞뒤의 ?, ', " 제거
|
||||
sas_token = self._sas_token.strip("?'\"")
|
||||
return (
|
||||
f"{self._base_url}/{self._user_uuid}/{self._task_id}/{category}/{file_name}?{sas_token}"
|
||||
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
|
||||
)
|
||||
|
||||
def _build_public_url(self, category: str, file_name: str) -> str:
|
||||
"""공개 URL 생성 (SAS 토큰 제외)"""
|
||||
return f"{self._base_url}/{self._user_uuid}/{self._task_id}/{category}/{file_name}"
|
||||
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
|
||||
|
||||
async def _upload_bytes(
|
||||
self,
|
||||
|
|
@ -261,7 +253,7 @@ class AzureBlobUploader:
|
|||
async def upload_music(self, file_path: str) -> bool:
|
||||
"""음악 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {user_uuid}/{task_id}/song/{파일명}
|
||||
URL 경로: {task_id}/song/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
|
@ -270,7 +262,7 @@ class AzureBlobUploader:
|
|||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
|
|
@ -287,7 +279,7 @@ class AzureBlobUploader:
|
|||
) -> bool:
|
||||
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {user_uuid}/{task_id}/song/{파일명}
|
||||
URL 경로: {task_id}/song/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
|
|
@ -297,7 +289,7 @@ class AzureBlobUploader:
|
|||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
|
|
@ -323,7 +315,7 @@ class AzureBlobUploader:
|
|||
async def upload_video(self, file_path: str) -> bool:
|
||||
"""영상 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {user_uuid}/{task_id}/video/{파일명}
|
||||
URL 경로: {task_id}/video/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
|
@ -332,7 +324,7 @@ class AzureBlobUploader:
|
|||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
|
|
@ -349,7 +341,7 @@ class AzureBlobUploader:
|
|||
) -> bool:
|
||||
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {user_uuid}/{task_id}/video/{파일명}
|
||||
URL 경로: {task_id}/video/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
|
|
@ -359,7 +351,7 @@ class AzureBlobUploader:
|
|||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
|
|
@ -385,7 +377,7 @@ class AzureBlobUploader:
|
|||
async def upload_image(self, file_path: str) -> bool:
|
||||
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
|
||||
|
||||
URL 경로: {user_uuid}/{task_id}/image/{파일명}
|
||||
URL 경로: {task_id}/image/{파일명}
|
||||
|
||||
Args:
|
||||
file_path: 업로드할 파일 경로
|
||||
|
|
@ -394,7 +386,7 @@ class AzureBlobUploader:
|
|||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
success = await uploader.upload_image(file_path="my_image.png")
|
||||
print(uploader.public_url)
|
||||
"""
|
||||
|
|
@ -414,7 +406,7 @@ class AzureBlobUploader:
|
|||
) -> bool:
|
||||
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
||||
|
||||
URL 경로: {user_uuid}/{task_id}/image/{파일명}
|
||||
URL 경로: {task_id}/image/{파일명}
|
||||
|
||||
Args:
|
||||
file_content: 업로드할 파일 바이트 데이터
|
||||
|
|
@ -424,7 +416,7 @@ class AzureBlobUploader:
|
|||
bool: 업로드 성공 여부
|
||||
|
||||
Example:
|
||||
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
||||
uploader = AzureBlobUploader(task_id="task-123")
|
||||
with open("my_image.png", "rb") as f:
|
||||
content = f.read()
|
||||
success = await uploader.upload_image_bytes(content, "my_image.png")
|
||||
|
|
@ -453,17 +445,17 @@ class AzureBlobUploader:
|
|||
# import asyncio
|
||||
#
|
||||
# async def main():
|
||||
# uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
||||
# uploader = AzureBlobUploader(task_id="task-123")
|
||||
#
|
||||
# # 음악 업로드 -> {BASE_URL}/user-abc/task-123/song/my_song.mp3
|
||||
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3
|
||||
# await uploader.upload_music("my_song.mp3")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
# # 영상 업로드 -> {BASE_URL}/user-abc/task-123/video/my_video.mp4
|
||||
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4
|
||||
# await uploader.upload_video("my_video.mp4")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
# # 이미지 업로드 -> {BASE_URL}/user-abc/task-123/image/my_image.png
|
||||
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png
|
||||
# await uploader.upload_image("my_image.png")
|
||||
# print(uploader.public_url)
|
||||
#
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ Video API Router
|
|||
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
|
||||
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
|
||||
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
|
||||
- GET /video/list: 완료된 영상 목록 조회 (페이지네이션)
|
||||
- GET /videos/: 완료된 영상 목록 조회 (페이지네이션)
|
||||
|
||||
사용 예시:
|
||||
from app.video.api.routers.v1.video import router
|
||||
app.include_router(router)
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -23,8 +23,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.database.session import get_session
|
||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
from app.home.models import Image, Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song, SongTimestamp
|
||||
|
|
@ -52,9 +50,6 @@ router = APIRouter(prefix="/video", tags=["Video"])
|
|||
description="""
|
||||
Creatomate API를 통해 영상 생성을 요청합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **task_id**: Project/Lyric/Song/Image의 task_id (필수) - 연관된 프로젝트, 가사, 노래, 이미지를 조회하는 데 사용
|
||||
|
||||
|
|
@ -73,15 +68,10 @@ Creatomate API를 통해 영상 생성을 요청합니다.
|
|||
- **creatomate_render_id**: Creatomate 렌더 ID (상태 조회에 사용)
|
||||
- **message**: 응답 메시지
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
# 세로형 영상 생성 (기본값)
|
||||
curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5fce431" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
|
||||
# 가로형 영상 생성
|
||||
curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
## 사용 예시
|
||||
```
|
||||
GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431
|
||||
GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
|
@ -96,7 +86,6 @@ curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5
|
|||
responses={
|
||||
200: {"description": "영상 생성 요청 성공"},
|
||||
400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"},
|
||||
500: {"description": "영상 생성 요청 실패"},
|
||||
},
|
||||
|
|
@ -107,7 +96,6 @@ async def generate_video(
|
|||
default="vertical",
|
||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> GenerateVideoResponse:
|
||||
"""Creatomate API를 통해 영상을 생성합니다.
|
||||
|
||||
|
|
@ -359,9 +347,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(
|
||||
|
|
@ -466,9 +454,6 @@ async def generate_video(
|
|||
Creatomate API를 통해 영상 생성 작업의 상태를 조회합니다.
|
||||
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블을 업데이트합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **creatomate_render_id**: 영상 생성 시 반환된 Creatomate 렌더 ID (필수)
|
||||
|
||||
|
|
@ -479,10 +464,9 @@ succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하
|
|||
- **render_data**: 렌더링 결과 데이터 (완료 시)
|
||||
- **raw_response**: Creatomate API 원본 응답
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/video/status/{creatomate_render_id}" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
## 사용 예시
|
||||
```
|
||||
GET /video/status/render-id-123...
|
||||
```
|
||||
|
||||
## 상태 값
|
||||
|
|
@ -499,14 +483,12 @@ curl -X GET "http://localhost:8000/video/status/{creatomate_render_id}" \\
|
|||
response_model=PollingVideoResponse,
|
||||
responses={
|
||||
200: {"description": "상태 조회 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
500: {"description": "상태 조회 실패"},
|
||||
},
|
||||
)
|
||||
async def get_video_status(
|
||||
creatomate_render_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PollingVideoResponse:
|
||||
"""creatomate_render_id로 영상 생성 작업의 상태를 조회합니다.
|
||||
|
|
@ -568,7 +550,6 @@ 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(
|
||||
|
|
@ -617,9 +598,6 @@ async def get_video_status(
|
|||
task_id를 기반으로 Video 테이블의 상태를 polling하고,
|
||||
completed인 경우 Project 정보와 영상 URL을 반환합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **task_id**: 프로젝트 task_id (필수)
|
||||
|
||||
|
|
@ -633,10 +611,9 @@ completed인 경우 Project 정보와 영상 URL을 반환합니다.
|
|||
- **result_movie_url**: 영상 결과 URL (completed 시)
|
||||
- **created_at**: 생성 일시
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/video/download/019123ab-cdef-7890-abcd-ef1234567890" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
## 사용 예시
|
||||
```
|
||||
GET /video/download/019123ab-cdef-7890-abcd-ef1234567890
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
|
@ -646,14 +623,12 @@ curl -X GET "http://localhost:8000/video/download/019123ab-cdef-7890-abcd-ef1234
|
|||
response_model=DownloadVideoResponse,
|
||||
responses={
|
||||
200: {"description": "조회 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
404: {"description": "Video를 찾을 수 없음"},
|
||||
500: {"description": "조회 실패"},
|
||||
},
|
||||
)
|
||||
async def download_video(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DownloadVideoResponse:
|
||||
"""task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다."""
|
||||
|
|
@ -733,14 +708,11 @@ async def download_video(
|
|||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
"s/",
|
||||
summary="생성된 영상 목록 조회",
|
||||
description="""
|
||||
완료된 영상 목록을 페이지네이션하여 조회합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 쿼리 파라미터
|
||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
||||
|
|
@ -754,10 +726,9 @@ async def download_video(
|
|||
- **has_next**: 다음 페이지 존재 여부
|
||||
- **has_prev**: 이전 페이지 존재 여부
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/video/list?page=1&page_size=10" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
## 사용 예시
|
||||
```
|
||||
GET /videos/?page=1&page_size=10
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
|
@ -768,12 +739,10 @@ curl -X GET "http://localhost:8000/video/list?page=1&page_size=10" \\
|
|||
response_model=PaginatedResponse[VideoListItem],
|
||||
responses={
|
||||
200: {"description": "영상 목록 조회 성공"},
|
||||
401: {"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]:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
|
@ -24,7 +24,7 @@ class Video(Base):
|
|||
project_id: 연결된 Project의 id (외래키)
|
||||
lyric_id: 연결된 Lyric의 id (외래키)
|
||||
song_id: 연결된 Song의 id (외래키)
|
||||
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
|
||||
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
|
||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
|
@ -37,11 +37,6 @@ class Video(Base):
|
|||
|
||||
__tablename__ = "video"
|
||||
__table_args__ = (
|
||||
Index("idx_video_task_id", "task_id"),
|
||||
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",
|
||||
|
|
@ -61,6 +56,7 @@ class Video(Base):
|
|||
Integer,
|
||||
ForeignKey("project.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Project의 id",
|
||||
)
|
||||
|
||||
|
|
@ -68,6 +64,7 @@ class Video(Base):
|
|||
Integer,
|
||||
ForeignKey("lyric.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Lyric의 id",
|
||||
)
|
||||
|
||||
|
|
@ -75,13 +72,15 @@ class Video(Base):
|
|||
Integer,
|
||||
ForeignKey("song.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="연결된 Song의 id",
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="영상 생성 작업 고유 식별자 (UUID7)",
|
||||
index=True,
|
||||
comment="영상 생성 작업 고유 식별자 (UUID)",
|
||||
)
|
||||
|
||||
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
|
||||
|
|
@ -102,13 +101,6 @@ 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,7 +106,6 @@ 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 테이블을 업데이트합니다.
|
||||
|
||||
|
|
@ -114,7 +113,6 @@ 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
|
||||
|
|
@ -144,7 +142,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(user_uuid=user_uuid, task_id=task_id)
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
|
||||
|
||||
if not upload_success:
|
||||
|
|
@ -192,7 +190,6 @@ 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 테이블을 업데이트합니다.
|
||||
|
||||
|
|
@ -200,7 +197,6 @@ 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
|
||||
|
|
@ -248,7 +244,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(user_uuid=user_uuid, task_id=task_id)
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
|
||||
|
||||
if not upload_success:
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ class KakaoSettings(BaseSettings):
|
|||
KAKAO_CLIENT_ID: str = Field(default="", description="카카오 REST API 키")
|
||||
KAKAO_CLIENT_SECRET: str = Field(default="", description="카카오 Client Secret (선택)")
|
||||
KAKAO_REDIRECT_URI: str = Field(
|
||||
default="http://localhost:8000/user/auth/kakao/callback",
|
||||
default="http://localhost:8000/api/v1/user/auth/kakao/callback",
|
||||
description="카카오 로그인 후 리다이렉트 URI",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
-- ============================================================================
|
||||
-- 마이그레이션: 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';
|
||||
|
|
@ -1,685 +0,0 @@
|
|||
# Access Token 인증 설계 문서
|
||||
|
||||
> 작성일: 2026-01-28
|
||||
> 목적: 모든 API 요청에서 액세스 토큰을 검증하여 로그인 사용자를 인증하는 최적의 설계 제안
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 시스템 분석
|
||||
|
||||
### 1.1 기존 인증 구조
|
||||
|
||||
#### 인증 의존성 (`app/user/dependencies/auth.py`)
|
||||
|
||||
현재 3가지 인증 의존성이 구현되어 있습니다:
|
||||
|
||||
| 의존성 | 용도 | 토큰 없음 시 |
|
||||
|--------|------|-------------|
|
||||
| `get_current_user()` | 필수 인증 | 예외 발생 (401) |
|
||||
| `get_current_user_optional()` | 선택적 인증 | `None` 반환 |
|
||||
| `get_current_admin()` | 관리자 전용 | 예외 발생 (403) |
|
||||
|
||||
#### JWT 토큰 구조 (`app/user/services/jwt.py`)
|
||||
|
||||
```python
|
||||
# Access Token Payload
|
||||
{
|
||||
"sub": user_uuid, # 사용자 고유 식별자 (UUID7)
|
||||
"exp": datetime, # 만료 시간
|
||||
"type": "access" # 토큰 타입
|
||||
}
|
||||
```
|
||||
|
||||
- **Access Token 유효기간**: `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` (설정 파일)
|
||||
- **Refresh Token 유효기간**: `JWT_REFRESH_TOKEN_EXPIRE_DAYS` (설정 파일)
|
||||
- **알고리즘**: HS256
|
||||
|
||||
#### 커스텀 예외 (`app/user/exceptions.py`)
|
||||
|
||||
| 예외 | HTTP 상태 | 코드 |
|
||||
|------|----------|------|
|
||||
| `MissingTokenError` | 401 | MISSING_TOKEN |
|
||||
| `InvalidTokenError` | 401 | INVALID_TOKEN |
|
||||
| `TokenExpiredError` | 401 | TOKEN_EXPIRED |
|
||||
| `TokenRevokedError` | 401 | TOKEN_REVOKED |
|
||||
| `UserNotFoundError` | 404 | USER_NOT_FOUND |
|
||||
| `UserInactiveError` | 403 | USER_INACTIVE |
|
||||
| `AdminRequiredError` | 403 | ADMIN_REQUIRED |
|
||||
|
||||
---
|
||||
|
||||
### 1.2 현재 엔드포인트 인증 현황
|
||||
|
||||
#### 인증이 적용된 엔드포인트 (3개)
|
||||
|
||||
| 엔드포인트 | 메서드 | 의존성 |
|
||||
|-----------|--------|--------|
|
||||
| `/auth/me` | GET | `get_current_user` |
|
||||
| `/auth/logout` | POST | `get_current_user` |
|
||||
| `/auth/logout/all` | POST | `get_current_user` |
|
||||
|
||||
#### 인증이 없는 엔드포인트 (13개)
|
||||
|
||||
| 모듈 | 엔드포인트 | 메서드 | 설명 |
|
||||
|------|-----------|--------|------|
|
||||
| **Home** | `/crawling` | POST | 네이버 지도 크롤링 |
|
||||
| **Home** | `/autocomplete` | POST | 자동완성 크롤링 |
|
||||
| **Home** | `/image/upload/server` | POST | 이미지 업로드 (로컬) |
|
||||
| **Home** | `/image/upload/blob` | POST | 이미지 업로드 (Azure Blob) |
|
||||
| **Lyric** | `/lyric/generate` | POST | 가사 생성 |
|
||||
| **Lyric** | `/lyric/status/{task_id}` | GET | 가사 상태 조회 |
|
||||
| **Lyric** | `/lyrics/` | GET | 가사 목록 조회 |
|
||||
| **Lyric** | `/lyric/{task_id}` | GET | 가사 상세 조회 |
|
||||
| **Song** | `/song/generate/{task_id}` | POST | 노래 생성 |
|
||||
| **Song** | `/song/status/{song_id}` | GET | 노래 상태 조회 |
|
||||
| **Video** | `/video/generate/{task_id}` | GET | 영상 생성 |
|
||||
| **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) |
|
||||
|
||||
---
|
||||
|
||||
### 1.3 모델의 user_uuid 외래키 현황
|
||||
|
||||
`user_uuid` 외래키는 **Project 테이블에만** 존재합니다. 하위 리소스(Lyric, Song, Video, Image)의 소유권은 Project를 통해 간접적으로 확인합니다.
|
||||
|
||||
| 모델 | user_uuid 필드 | nullable | 비고 |
|
||||
|------|-----------------|----------|------|
|
||||
| Project | `user_uuid` → User.user_uuid | True | ✅ 소유권 기준 |
|
||||
| Image | ❌ 없음 | - | task_id로 Project 연결 |
|
||||
| Lyric | ❌ 없음 | - | project_id로 Project 연결 |
|
||||
| Song | ❌ 없음 | - | project_id로 Project 연결 |
|
||||
| Video | ❌ 없음 | - | project_id로 Project 연결 |
|
||||
|
||||
**소유권 확인 흐름:**
|
||||
```
|
||||
Lyric/Song/Video → project_id → Project → user_uuid → User
|
||||
Image → task_id → Project (같은 task_id) → user_uuid → User
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 방안 비교
|
||||
|
||||
### 2.1 방안 A: 의존성 주입 방식 (Dependency Injection)
|
||||
|
||||
각 엔드포인트에 개별적으로 인증 의존성을 추가하는 방식입니다.
|
||||
|
||||
```python
|
||||
# 예시: 필수 인증
|
||||
@router.post("/lyric/generate")
|
||||
async def generate_lyric(
|
||||
request_body: GenerateLyricRequest,
|
||||
current_user: User = Depends(get_current_user), # 인증 추가
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# current_user.user_uuid 사용 가능
|
||||
...
|
||||
|
||||
# 예시: 선택적 인증
|
||||
@router.get("/lyrics/")
|
||||
async def list_lyrics(
|
||||
current_user: User | None = Depends(get_current_user_optional), # 선택적 인증
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
if current_user:
|
||||
# 로그인 사용자: 자신의 가사만 조회
|
||||
...
|
||||
else:
|
||||
# 비로그인 사용자: 공개 가사 조회
|
||||
...
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- FastAPI 표준 패턴 (공식 문서 권장)
|
||||
- 엔드포인트별 세밀한 제어 가능
|
||||
- 테스트 작성이 용이 (의존성 오버라이드)
|
||||
- 기존 코드와의 호환성 우수
|
||||
|
||||
**단점:**
|
||||
- 각 엔드포인트에 개별 적용 필요
|
||||
- 실수로 인증 누락 가능
|
||||
|
||||
---
|
||||
|
||||
### 2.2 방안 B: 미들웨어 방식 (Middleware)
|
||||
|
||||
모든 요청에 미들웨어가 자동으로 토큰을 검증하는 방식입니다.
|
||||
|
||||
```python
|
||||
# app/middleware/auth.py
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
# 인증 예외 경로
|
||||
EXEMPT_PATHS = [
|
||||
"/docs", "/openapi.json", "/health",
|
||||
"/auth/kakao/login", "/auth/kakao/callback", "/auth/kakao/verify",
|
||||
"/auth/refresh",
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 예외 경로는 통과
|
||||
if any(request.url.path.startswith(p) for p in self.EXEMPT_PATHS):
|
||||
return await call_next(request)
|
||||
|
||||
# 토큰 검증
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(status_code=401, content={"code": "MISSING_TOKEN"})
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return JSONResponse(status_code=401, content={"code": "INVALID_TOKEN"})
|
||||
|
||||
# request.state에 사용자 정보 저장
|
||||
request.state.user_uuid = payload.get("sub")
|
||||
return await call_next(request)
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 모든 엔드포인트에 자동 적용
|
||||
- 중앙 집중식 관리
|
||||
- 인증 누락 방지
|
||||
|
||||
**단점:**
|
||||
- 예외 경로 관리가 복잡해질 수 있음
|
||||
- DB 조회를 미들웨어에서 처리하면 성능 이슈
|
||||
- 선택적 인증 구현이 어려움
|
||||
- FastAPI의 의존성 주입 패턴과 맞지 않음
|
||||
|
||||
---
|
||||
|
||||
### 2.3 방안 C: 라우터 레벨 의존성 (Router-Level Dependencies) ⭐ 권장
|
||||
|
||||
라우터 전체에 기본 인증을 적용하고, 개별 엔드포인트에서 오버라이드하는 방식입니다.
|
||||
|
||||
```python
|
||||
# 인증이 필요한 라우터
|
||||
lyric_router = APIRouter(
|
||||
prefix="/lyric",
|
||||
tags=["Lyric"],
|
||||
dependencies=[Depends(get_current_user_optional)], # 라우터 전체에 적용
|
||||
)
|
||||
|
||||
# 필수 인증이 필요한 엔드포인트
|
||||
@lyric_router.post(
|
||||
"/generate",
|
||||
dependencies=[Depends(get_current_user)], # 오버라이드: 필수 인증
|
||||
)
|
||||
async def generate_lyric(...):
|
||||
...
|
||||
|
||||
# 선택적 인증 (라우터 기본값 사용)
|
||||
@lyric_router.get("/lyrics/")
|
||||
async def list_lyrics(...):
|
||||
...
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 모듈별 일관된 인증 정책 적용
|
||||
- 엔드포인트별 세밀한 제어 가능
|
||||
- FastAPI 표준 패턴 준수
|
||||
- 인증 누락 가능성 감소
|
||||
|
||||
**단점:**
|
||||
- 라우터 수정 필요
|
||||
- 새 엔드포인트 추가 시 주의 필요
|
||||
|
||||
---
|
||||
|
||||
## 3. 권장 설계안
|
||||
|
||||
### 3.1 최적 설계: 의존성 주입 방식 (방안 A) + 라우터 레벨 기본값 (방안 C)
|
||||
|
||||
#### 핵심 원칙
|
||||
|
||||
1. **데이터 생성 엔드포인트**: 필수 인증 (`get_current_user`)
|
||||
2. **데이터 조회 엔드포인트**: 선택적 인증 (`get_current_user_optional`)
|
||||
3. **공개 엔드포인트**: 인증 없음 (로그인, 콜백 등)
|
||||
|
||||
#### 엔드포인트별 인증 정책
|
||||
|
||||
| 엔드포인트 | 인증 타입 | 이유 |
|
||||
|-----------|----------|------|
|
||||
| `/auth/kakao/login` | 없음 | 로그인 진입점 |
|
||||
| `/auth/kakao/callback` | 없음 | OAuth 콜백 |
|
||||
| `/auth/kakao/verify` | 없음 | 토큰 발급 |
|
||||
| `/auth/refresh` | 없음 | 토큰 갱신 |
|
||||
| `/auth/me` | **필수** | 내 정보 조회 |
|
||||
| `/auth/logout` | **필수** | 로그아웃 |
|
||||
| `/auth/logout/all` | **필수** | 전체 로그아웃 |
|
||||
| `/crawling` | **선택적** | 비로그인도 테스트 가능 |
|
||||
| `/autocomplete` | **선택적** | 비로그인도 테스트 가능 |
|
||||
| `/image/upload/blob` | **필수** | 리소스 생성 |
|
||||
| `/lyric/generate` | **필수** | 리소스 생성 |
|
||||
| `/lyric/status/{task_id}` | **선택적** | 상태 조회 |
|
||||
| `/lyric/{task_id}` | **선택적** | 상세 조회 |
|
||||
| `/lyrics/` | **선택적** | 목록 조회 |
|
||||
| `/song/generate/{task_id}` | **필수** | 리소스 생성 |
|
||||
| `/song/status/{song_id}` | **선택적** | 상태 조회 |
|
||||
| `/video/generate/{task_id}` | **필수** | 리소스 생성 |
|
||||
| `/video/status/{...}` | **선택적** | 상태 조회 |
|
||||
| `/video/download/{task_id}` | **선택적** | 다운로드 |
|
||||
| `/videos/` | **선택적** | 목록 조회 |
|
||||
| `/archive/videos/` | **필수** | 완료된 영상 목록 조회 (아카이브) |
|
||||
| `/archive/videos/{task_id}` | **필수** | 아카이브 영상 삭제 + 소유권 검증 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 구현 코드 예시
|
||||
|
||||
#### 3.2.1 리소스 생성 엔드포인트 (필수 인증)
|
||||
|
||||
```python
|
||||
# app/lyric/api/routers/v1/lyric.py
|
||||
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import User
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_lyric(
|
||||
request_body: GenerateLyricRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user), # ✅ 필수 인증
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> GenerateLyricResponse:
|
||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||
|
||||
# Project 생성 시 user_uuid 연결 (소유권의 기준점)
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
task_id=task_id,
|
||||
user_uuid=current_user.user_uuid, # ✅ 사용자 연결
|
||||
...
|
||||
)
|
||||
|
||||
# Lyric은 project_id를 통해 소유권 확인 (user_uuid 없음)
|
||||
lyric = Lyric(
|
||||
project_id=project.id, # ✅ Project 연결 → 소유권 간접 확인
|
||||
task_id=task_id,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
#### 3.2.2 데이터 조회 엔드포인트 (선택적 인증)
|
||||
|
||||
```python
|
||||
# app/lyric/api/routers/v1/lyric.py
|
||||
|
||||
from app.user.dependencies import get_current_user_optional
|
||||
from app.user.models import User
|
||||
|
||||
@router.get("/lyrics/")
|
||||
async def list_lyrics(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User | None = Depends(get_current_user_optional), # ✅ 선택적 인증
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PaginatedResponse[LyricListItem]:
|
||||
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
||||
|
||||
base_query = select(Lyric).where(Lyric.status == "completed")
|
||||
|
||||
# 로그인 사용자: Project.user_uuid를 통해 자신의 가사만 조회
|
||||
if current_user:
|
||||
base_query = (
|
||||
base_query
|
||||
.join(Project, Lyric.project_id == Project.id)
|
||||
.where(Project.user_uuid == current_user.user_uuid) # ✅ Project를 통한 소유자 필터
|
||||
)
|
||||
else:
|
||||
# 비로그인 사용자: 공개 데이터만 조회 (Project.user_uuid가 NULL)
|
||||
base_query = (
|
||||
base_query
|
||||
.join(Project, Lyric.project_id == Project.id)
|
||||
.where(Project.user_uuid.is_(None))
|
||||
)
|
||||
|
||||
# 페이지네이션 처리
|
||||
...
|
||||
```
|
||||
|
||||
#### 3.2.3 이미지 업로드 (필수 인증 + user_uuid 폴더 구조)
|
||||
|
||||
```python
|
||||
# app/home/api/routers/v1/home.py
|
||||
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import User
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
|
||||
@router.post("/image/upload/blob")
|
||||
async def upload_images_blob(
|
||||
images_json: Optional[str] = Form(default=None),
|
||||
files: Optional[list[UploadFile]] = File(default=None),
|
||||
current_user: User = Depends(get_current_user), # ✅ 필수 인증
|
||||
) -> ImageUploadResponse:
|
||||
"""이미지 업로드 (URL + Azure Blob Storage)"""
|
||||
|
||||
task_id = await generate_task_id()
|
||||
|
||||
# ✅ user_uuid를 포함한 Blob 업로더 생성
|
||||
uploader = AzureBlobUploader(
|
||||
user_uuid=current_user.user_uuid,
|
||||
task_id=task_id,
|
||||
)
|
||||
# Blob 경로: {BASE_URL}/{user_uuid}/{task_id}/image/{file_name}
|
||||
|
||||
# Image 저장 (user_uuid 없음 - task_id로 Project와 연결)
|
||||
image = Image(
|
||||
task_id=task_id, # ✅ task_id를 통해 Project와 연결 → 소유권 확인
|
||||
img_name=img_name,
|
||||
img_url=blob_url,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
> **Note**: Image 테이블에는 `user_uuid` 필드가 없습니다.
|
||||
> 소유권은 `task_id`를 공유하는 Project를 통해 확인합니다.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 소유권 검증 유틸리티
|
||||
|
||||
데이터 조회/수정 시 **Project를 통한** 소유권 검증을 위한 유틸리티 함수를 추가합니다.
|
||||
|
||||
```python
|
||||
# app/utils/ownership.py
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.user.models import User
|
||||
from app.home.models import Project
|
||||
|
||||
|
||||
def verify_ownership(
|
||||
project_user_uuid: Optional[str],
|
||||
current_user: Optional[User],
|
||||
raise_on_mismatch: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Project 기반 리소스 소유권 검증
|
||||
|
||||
Args:
|
||||
project_user_uuid: Project의 user_uuid
|
||||
current_user: 현재 로그인 사용자 (None이면 비로그인)
|
||||
raise_on_mismatch: True면 불일치 시 예외 발생
|
||||
|
||||
Returns:
|
||||
bool: 소유권 일치 여부
|
||||
|
||||
Raises:
|
||||
HTTPException: 소유권 불일치 시 (raise_on_mismatch=True)
|
||||
"""
|
||||
# Project에 소유자가 없으면 모두 접근 가능 (공개 리소스)
|
||||
if project_user_uuid is None:
|
||||
return True
|
||||
|
||||
# 비로그인 사용자가 소유자가 있는 리소스에 접근
|
||||
if current_user is None:
|
||||
if raise_on_mismatch:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "AUTH_REQUIRED", "message": "로그인이 필요합니다."},
|
||||
)
|
||||
return False
|
||||
|
||||
# 소유자 불일치
|
||||
if project_user_uuid != current_user.user_uuid:
|
||||
if raise_on_mismatch:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "FORBIDDEN", "message": "접근 권한이 없습니다."},
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def get_project_owner_uuid(
|
||||
session: AsyncSession,
|
||||
project_id: int,
|
||||
) -> Optional[str]:
|
||||
"""project_id로 소유자의 user_uuid 조회"""
|
||||
result = await session.execute(
|
||||
select(Project.user_uuid).where(Project.id == project_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
```
|
||||
|
||||
**사용 예시:**
|
||||
|
||||
```python
|
||||
@router.get("/{task_id}")
|
||||
async def get_lyric_detail(
|
||||
task_id: str,
|
||||
current_user: User | None = Depends(get_current_user_optional),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LyricDetailResponse:
|
||||
"""task_id로 생성된 가사를 조회합니다."""
|
||||
|
||||
lyric = await get_lyric_by_task_id(session, task_id)
|
||||
|
||||
# ✅ Project를 통한 소유권 검증
|
||||
project_user_uuid = await get_project_owner_uuid(session, lyric.project_id)
|
||||
verify_ownership(project_user_uuid, current_user)
|
||||
|
||||
return LyricDetailResponse(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 구현 체크리스트
|
||||
|
||||
### 4.1 Phase 1: 인증 인프라 확장
|
||||
|
||||
- [ ] `app/utils/ownership.py` 생성 (소유권 검증 유틸리티)
|
||||
- [ ] `AzureBlobUploader` 클래스에 `user_uuid` 파라미터 활성화
|
||||
- [ ] 기존 `get_current_user`, `get_current_user_optional` 테스트
|
||||
|
||||
### 4.2 Phase 2: 리소스 생성 엔드포인트 인증 적용
|
||||
|
||||
- [ ] `POST /image/upload/blob` - 필수 인증 (Image에는 user_uuid 없음, task_id로 연결)
|
||||
- [ ] `POST /lyric/generate` - 필수 인증 + Project.user_uuid 저장
|
||||
- [ ] `POST /song/generate/{task_id}` - 필수 인증 (Project 통해 소유권 확인)
|
||||
- [ ] `GET /video/generate/{task_id}` - 필수 인증 (Project 통해 소유권 확인)
|
||||
|
||||
### 4.3 Phase 3: 조회 엔드포인트 인증 적용
|
||||
|
||||
- [ ] `GET /lyric/status/{task_id}` - 선택적 인증 + Project 통해 소유권 검증
|
||||
- [ ] `GET /lyric/{task_id}` - 선택적 인증 + Project 통해 소유권 검증
|
||||
- [ ] `GET /lyrics/` - 선택적 인증 + Project.user_uuid 기반 필터
|
||||
- [ ] `GET /song/status/{song_id}` - 선택적 인증 + Project 통해 소유권 검증
|
||||
- [ ] `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: 크롤링 엔드포인트
|
||||
|
||||
- [ ] `POST /crawling` - 선택적 인증 (비로그인도 허용)
|
||||
- [ ] `POST /autocomplete` - 선택적 인증 (비로그인도 허용)
|
||||
|
||||
### 4.5 Phase 5: OpenAPI 문서 업데이트
|
||||
|
||||
- [ ] `main.py`의 `custom_openapi()` 수정하여 인증이 필요한 엔드포인트에 security 표시
|
||||
|
||||
---
|
||||
|
||||
## 5. OpenAPI Security 스키마 업데이트
|
||||
|
||||
`main.py`의 `custom_openapi()` 함수를 수정하여 인증이 필요한 엔드포인트를 표시합니다.
|
||||
|
||||
```python
|
||||
def custom_openapi():
|
||||
"""커스텀 OpenAPI 스키마 생성 (Bearer 인증 추가)"""
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
description=app.description,
|
||||
routes=app.routes,
|
||||
tags=tags_metadata,
|
||||
)
|
||||
|
||||
# Bearer 토큰 인증 스키마 추가
|
||||
openapi_schema["components"]["securitySchemes"] = {
|
||||
"BearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "JWT 액세스 토큰을 입력하세요.",
|
||||
}
|
||||
}
|
||||
|
||||
# 인증이 필요한 경로 패턴
|
||||
AUTH_REQUIRED_PATHS = [
|
||||
"/auth/me", "/auth/logout",
|
||||
"/image/upload/blob",
|
||||
"/lyric/generate",
|
||||
"/song/generate",
|
||||
"/video/generate",
|
||||
"/archive/videos", # GET (목록조회), DELETE (삭제)
|
||||
]
|
||||
|
||||
AUTH_OPTIONAL_PATHS = [
|
||||
"/crawling", "/autocomplete",
|
||||
"/lyric/status", "/lyric/",
|
||||
"/lyrics/",
|
||||
"/song/status",
|
||||
"/video/status", "/video/download",
|
||||
"/videos/",
|
||||
]
|
||||
|
||||
for path, path_item in openapi_schema["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method in ["get", "post", "put", "patch", "delete"]:
|
||||
# 필수 인증
|
||||
if any(auth_path in path for auth_path in AUTH_REQUIRED_PATHS):
|
||||
operation["security"] = [{"BearerAuth": []}]
|
||||
# 선택적 인증 (문서에는 표시하지만 필수 아님)
|
||||
elif any(auth_path in path for auth_path in AUTH_OPTIONAL_PATHS):
|
||||
operation["security"] = [{"BearerAuth": []}, {}] # 빈 객체 = 인증 없이도 가능
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 전략
|
||||
|
||||
### 6.1 단위 테스트
|
||||
|
||||
```python
|
||||
# tests/test_auth_dependencies.py
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from app.user.dependencies import get_current_user, get_current_user_optional
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_missing_token():
|
||||
"""토큰 없이 접근 시 MissingTokenError 발생"""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_user(credentials=None, session=mock_session)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail["code"] == "MISSING_TOKEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_optional_missing_token():
|
||||
"""선택적 인증에서 토큰 없으면 None 반환"""
|
||||
result = await get_current_user_optional(credentials=None, session=mock_session)
|
||||
assert result is None
|
||||
```
|
||||
|
||||
### 6.2 통합 테스트
|
||||
|
||||
```python
|
||||
# tests/test_lyric_auth.py
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_lyric_without_auth(client: AsyncClient):
|
||||
"""인증 없이 가사 생성 시 401 반환"""
|
||||
response = await client.post("/lyric/generate", json={...})
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"]["code"] == "MISSING_TOKEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_lyric_with_auth(client: AsyncClient, auth_headers: dict):
|
||||
"""인증된 사용자의 가사 생성 성공"""
|
||||
response = await client.post(
|
||||
"/lyric/generate",
|
||||
json={...},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 고려사항
|
||||
|
||||
### 7.1 기존 데이터 처리
|
||||
|
||||
현재 Project 테이블에서 `user_uuid`가 `NULL`인 레코드들에 대한 처리 방안:
|
||||
|
||||
1. **기존 데이터 유지**: `Project.user_uuid`가 `NULL`이면 해당 Project 및 하위 리소스(Lyric, Song, Video, Image)를 공개 데이터로 취급
|
||||
2. **관리자 전용**: `Project.user_uuid`가 `NULL`인 데이터는 관리자만 접근 가능
|
||||
3. **마이그레이션**: 특정 사용자에게 기존 Project 할당 (비권장)
|
||||
|
||||
**권장**: 옵션 1 (기존 데이터는 공개 데이터로 유지)
|
||||
|
||||
> **Note**: `user_uuid`는 Project 테이블에만 존재합니다.
|
||||
> Lyric, Song, Video, Image 테이블에는 `user_uuid` 필드가 없으며,
|
||||
> 소유권은 Project를 통해 간접적으로 확인합니다.
|
||||
|
||||
### 7.2 하위 호환성
|
||||
|
||||
- 기존 API 클라이언트가 인증 없이 호출하는 경우 401 응답
|
||||
- 프론트엔드 업데이트 필요: 모든 API 호출에 `Authorization` 헤더 추가
|
||||
- 점진적 적용: Phase별로 적용하여 영향 범위 최소화
|
||||
|
||||
---
|
||||
|
||||
## 8. 결론
|
||||
|
||||
### 권장 구현 순서
|
||||
|
||||
1. **의존성 주입 방식** 채택 (FastAPI 표준 패턴)
|
||||
2. **필수/선택적 인증** 엔드포인트별 적용
|
||||
3. **Project 기반 소유권 검증** 유틸리티 활용
|
||||
4. **Project.user_uuid 연결** 리소스 생성 시 적용
|
||||
|
||||
### 데이터 모델 구조
|
||||
|
||||
```
|
||||
User (user_uuid)
|
||||
└── Project (user_uuid → User) ← 소유권의 기준점
|
||||
├── Lyric (project_id → Project)
|
||||
│ └── Song (lyric_id → Lyric)
|
||||
│ └── Video (song_id → Song)
|
||||
└── Image (task_id = Project.task_id)
|
||||
```
|
||||
|
||||
이 설계를 통해:
|
||||
- **Project 테이블만** `user_uuid`를 가지며, 모든 소유권 확인의 기준점 역할
|
||||
- 하위 리소스(Lyric, Song, Video, Image)는 Project를 통해 간접적으로 소유권 확인
|
||||
- 사용자별 데이터 격리가 가능합니다
|
||||
- 비로그인 사용자도 제한된 기능을 사용할 수 있습니다
|
||||
- Azure Blob Storage 폴더 구조에 user_uuid가 포함됩니다
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,143 +0,0 @@
|
|||
# 소프트 삭제 (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;
|
||||
```
|
||||
112
main.py
112
main.py
|
|
@ -8,12 +8,11 @@ from app.admin_manager import init_admin
|
|||
from app.core.common import lifespan
|
||||
from app.database.session import engine
|
||||
|
||||
# User 모델 import (테이블 메타데이터 등록용)
|
||||
# 주의: User 모델을 먼저 import해야 UserProject가 User를 참조할 수 있음
|
||||
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.user.api.routers.v1.auth import router as auth_router
|
||||
from app.lyric.api.routers.v1.lyric import router as lyric_router
|
||||
from app.song.api.routers.v1.song import router as song_router
|
||||
from app.video.api.routers.v1.video import router as video_router
|
||||
|
|
@ -27,24 +26,16 @@ tags_metadata = [
|
|||
|
||||
## 인증 흐름
|
||||
|
||||
1. `GET /user/auth/kakao/login` - 카카오 로그인 URL 획득
|
||||
1. `GET /api/v1/user/auth/kakao/login` - 카카오 로그인 URL 획득
|
||||
2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인
|
||||
3. 카카오에서 인가 코드(code) 발급
|
||||
4. `POST /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
|
||||
4. `POST /api/v1/user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
|
||||
5. 이후 API 호출 시 `Authorization: Bearer {access_token}` 헤더 사용
|
||||
|
||||
## 토큰 관리
|
||||
|
||||
- **Access Token**: 1시간 유효, API 호출 시 사용
|
||||
- **Refresh Token**: 7일 유효, Access Token 갱신 시 사용
|
||||
|
||||
## Scalar에서 인증 사용하기
|
||||
|
||||
1. 카카오 로그인 또는 테스트 토큰 발급으로 `access_token` 획득
|
||||
2. 우측 상단 **Authorize** 버튼 클릭
|
||||
3. `access_token` 값 입력 (Bearer 접두사 없이 토큰만 입력)
|
||||
4. **Authorize** 클릭하여 저장
|
||||
5. 이후 인증이 필요한 API 호출 시 자동으로 토큰이 포함됨
|
||||
""",
|
||||
},
|
||||
# {
|
||||
|
|
@ -53,100 +44,46 @@ tags_metadata = [
|
|||
# },
|
||||
{
|
||||
"name": "Crawling",
|
||||
"description": """네이버 지도 크롤링 API - 장소 정보 및 이미지 수집
|
||||
|
||||
**인증: 불필요** (공개 API)
|
||||
""",
|
||||
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
|
||||
},
|
||||
{
|
||||
"name": "Image-Blob",
|
||||
"description": """이미지 업로드 API - Azure Blob Storage
|
||||
|
||||
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||
""",
|
||||
"description": "이미지 업로드 API - Azure Blob Storage",
|
||||
},
|
||||
{
|
||||
"name": "Lyric",
|
||||
"description": """가사 생성 및 관리 API
|
||||
|
||||
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||
|
||||
## 가사 생성 흐름
|
||||
|
||||
1. `POST /lyric/generate` - 가사 생성 요청 (백그라운드 처리)
|
||||
2. `GET /lyric/status/{task_id}` - 생성 상태 확인
|
||||
3. `GET /lyric/{task_id}` - 생성된 가사 조회
|
||||
4. `GET /lyric/list` - 가사 목록 조회 (페이지네이션)
|
||||
1. `POST /api/v1/lyric/generate` - 가사 생성 요청 (백그라운드 처리)
|
||||
2. `GET /api/v1/lyric/status/{task_id}` - 생성 상태 확인
|
||||
3. `GET /api/v1/lyric/{task_id}` - 생성된 가사 조회
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Song",
|
||||
"description": """노래 생성 및 관리 API (Suno AI)
|
||||
|
||||
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||
|
||||
## 노래 생성 흐름
|
||||
|
||||
1. `POST /song/generate/{task_id}` - 노래 생성 요청
|
||||
2. `GET /song/status/{song_id}` - Suno API 상태 확인
|
||||
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
|
||||
2. `GET /api/v1/song/status/{song_id}` - Suno API 상태 확인
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Video",
|
||||
"description": """영상 생성 및 관리 API (Creatomate)
|
||||
|
||||
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||
|
||||
## 영상 생성 흐름
|
||||
|
||||
1. `GET /video/generate/{task_id}` - 영상 생성 요청
|
||||
2. `GET /video/status/{creatomate_render_id}` - Creatomate 상태 확인
|
||||
3. `GET /video/download/{task_id}` - 영상 다운로드 URL 조회
|
||||
4. `GET /video/list` - 영상 목록 조회 (페이지네이션)
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Archive",
|
||||
"description": """아카이브 API - 완료된 영상 목록 조회 및 삭제
|
||||
|
||||
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- `GET /archive/videos/` - 완료된 영상 목록 페이지네이션 조회
|
||||
- `DELETE /archive/videos/delete/{task_id}` - 아카이브 영상 소프트 삭제
|
||||
|
||||
## 참고
|
||||
|
||||
- **본인 소유의 데이터만 조회/삭제 가능합니다.**
|
||||
- status가 'completed'인 영상만 반환됩니다.
|
||||
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
|
||||
- created_at 기준 내림차순 정렬됩니다.
|
||||
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
|
||||
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
|
||||
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 조회
|
||||
""",
|
||||
},
|
||||
]
|
||||
|
||||
# DEBUG 모드에서만 Test Auth 태그 추가
|
||||
if prj_settings.DEBUG:
|
||||
tags_metadata.append(
|
||||
{
|
||||
"name": "Test Auth",
|
||||
"description": """테스트용 인증 API (DEBUG 모드 전용)
|
||||
|
||||
**주의: 이 API는 DEBUG 모드에서만 사용 가능합니다.**
|
||||
|
||||
카카오 로그인 없이 테스트용 사용자 생성 및 토큰 발급이 가능합니다.
|
||||
|
||||
## 테스트 흐름
|
||||
|
||||
1. `POST /user/auth/test/create-user` - 테스트 사용자 생성
|
||||
2. `POST /user/auth/test/generate-token` - JWT 토큰 발급
|
||||
""",
|
||||
}
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title=prj_settings.PROJECT_NAME,
|
||||
version=prj_settings.VERSION,
|
||||
|
|
@ -181,24 +118,12 @@ 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"]:
|
||||
# 공개 엔드포인트가 아닌 경우 인증 필요
|
||||
is_public = any(public_path in path for public_path in public_endpoints)
|
||||
if not is_public and path.startswith("/api/"):
|
||||
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
|
||||
if any(auth_path in path for auth_path in ["/auth/me", "/auth/logout"]):
|
||||
operation["security"] = [{"BearerAuth": []}]
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
|
|
@ -238,8 +163,3 @@ 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:
|
||||
app.include_router(auth_test_router, prefix="/user") # Test Auth API 라우터
|
||||
|
|
|
|||
Loading…
Reference in New Issue