Compare commits
No commits in common. "main" and "20260126-v1.1.0" have entirely different histories.
main
...
20260126-v
|
|
@ -46,7 +46,3 @@ logs/
|
|||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
|
||||
*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
|
@ -1 +1 @@
|
|||
3.13.11
|
||||
3.14
|
||||
|
|
|
|||
|
|
@ -161,9 +161,6 @@ uv sync
|
|||
|
||||
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
|
||||
uv sync --active
|
||||
|
||||
playwright install
|
||||
playwright install-deps
|
||||
```
|
||||
|
||||
### 서버 실행
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ from app.database.session import engine
|
|||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||
from app.song.api.song_admin import SongAdmin
|
||||
from app.sns.api.sns_admin import SNSUploadTaskAdmin
|
||||
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
||||
from app.video.api.video_admin import VideoAdmin
|
||||
from config import prj_settings
|
||||
|
||||
|
|
@ -37,12 +35,4 @@ def init_admin(
|
|||
# 영상 관리
|
||||
admin.add_view(VideoAdmin)
|
||||
|
||||
# 사용자 관리
|
||||
admin.add_view(UserAdmin)
|
||||
admin.add_view(RefreshTokenAdmin)
|
||||
admin.add_view(SocialAccountAdmin)
|
||||
|
||||
# SNS 관리
|
||||
admin.add_view(SNSUploadTaskAdmin)
|
||||
|
||||
return admin
|
||||
|
|
|
|||
|
|
@ -1,349 +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**: 영상 목록 (video_id, 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의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다.
|
||||
- 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 - user: {current_user.user_uuid}, "
|
||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
)
|
||||
|
||||
try:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
# 서브쿼리: task_id별 최신 Video ID 추출
|
||||
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치
|
||||
latest_video_ids = (
|
||||
select(func.max(Video.id).label("latest_id"))
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(
|
||||
Project.user_uuid == current_user.user_uuid,
|
||||
Video.status == "completed",
|
||||
Video.is_deleted == False, # noqa: E712
|
||||
Project.is_deleted == False, # noqa: E712
|
||||
)
|
||||
.group_by(Video.task_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만)
|
||||
count_query = select(func.count(Video.id)).where(
|
||||
Video.id.in_(select(latest_video_ids.c.latest_id))
|
||||
)
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
|
||||
data_query = (
|
||||
select(Video, Project)
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
||||
.order_by(Video.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
)
|
||||
result = await session.execute(data_query)
|
||||
rows = result.all()
|
||||
|
||||
# VideoListItem으로 변환
|
||||
items = [
|
||||
VideoListItem(
|
||||
video_id=video.id,
|
||||
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,
|
||||
)
|
||||
for video, project in rows
|
||||
]
|
||||
|
||||
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/{video_id}",
|
||||
summary="개별 영상 소프트 삭제",
|
||||
description="""
|
||||
## 개요
|
||||
video_id에 해당하는 영상만 소프트 삭제합니다.
|
||||
(is_deleted=True로 설정, 실제 데이터는 DB에 유지)
|
||||
|
||||
## 경로 파라미터
|
||||
- **video_id**: 삭제할 영상의 ID (Video.id)
|
||||
|
||||
## 참고
|
||||
- 본인이 소유한 프로젝트의 영상만 삭제할 수 있습니다.
|
||||
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
|
||||
- 프로젝트나 다른 관련 데이터(Song, Lyric 등)는 삭제되지 않습니다.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "삭제 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
403: {"description": "삭제 권한 없음"},
|
||||
404: {"description": "영상을 찾을 수 없음"},
|
||||
500: {"description": "삭제 실패"},
|
||||
},
|
||||
)
|
||||
async def delete_single_video(
|
||||
video_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""video_id에 해당하는 개별 영상만 소프트 삭제합니다."""
|
||||
logger.info(f"[delete_single_video] START - video_id: {video_id}, user: {current_user.user_uuid}")
|
||||
|
||||
try:
|
||||
# Video 조회 (Project와 함께)
|
||||
result = await session.execute(
|
||||
select(Video, Project)
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(
|
||||
Video.id == video_id,
|
||||
Video.is_deleted == False,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
|
||||
if row is None:
|
||||
logger.warning(f"[delete_single_video] NOT FOUND - video_id: {video_id}")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="영상을 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
video, project = row
|
||||
|
||||
# 소유권 검증
|
||||
if project.user_uuid != current_user.user_uuid:
|
||||
logger.warning(
|
||||
f"[delete_single_video] FORBIDDEN - video_id: {video_id}, "
|
||||
f"owner: {project.user_uuid}, requester: {current_user.user_uuid}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="삭제 권한이 없습니다.",
|
||||
)
|
||||
|
||||
# 소프트 삭제
|
||||
video.is_deleted = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[delete_single_video] SUCCESS - video_id: {video_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "영상이 삭제되었습니다.",
|
||||
"video_id": video_id,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[delete_single_video] EXCEPTION - video_id: {video_id}, error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"삭제에 실패했습니다: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/videos/delete/{task_id}",
|
||||
summary="프로젝트 전체 소프트 삭제 (task_id 기준)",
|
||||
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 형식)
|
||||
|
||||
## 참고
|
||||
- 본인이 소유한 프로젝트만 삭제할 수 있습니다.
|
||||
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
|
||||
- 백그라운드에서 비동기로 처리됩니다.
|
||||
- **개별 영상만 삭제하려면 DELETE /archive/videos/{video_id}를 사용하세요.**
|
||||
""",
|
||||
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
|
||||
|
|
@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
|||
from fastapi import FastAPI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||
|
||||
logger = get_logger("core")
|
||||
|
||||
|
||||
|
|
@ -24,7 +24,6 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
await create_db_tables()
|
||||
logger.info("Database tables created (DEBUG mode)")
|
||||
await NvMapPwScraper.initiate_scraper()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Database initialization timed out")
|
||||
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||
|
|
|
|||
|
|
@ -288,33 +288,13 @@ def add_exception_handlers(app: FastAPI):
|
|||
),
|
||||
)
|
||||
|
||||
# SocialException 핸들러 추가
|
||||
from app.social.exceptions import SocialException
|
||||
|
||||
from app.social.exceptions import TokenExpiredError
|
||||
|
||||
@app.exception_handler(SocialException)
|
||||
def social_exception_handler(request: Request, exc: SocialException) -> Response:
|
||||
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
|
||||
content = {
|
||||
"detail": exc.message,
|
||||
"code": exc.code,
|
||||
}
|
||||
# TokenExpiredError인 경우 재연동 정보 추가
|
||||
if isinstance(exc, TokenExpiredError):
|
||||
content["platform"] = exc.platform
|
||||
content["reconnect_url"] = f"/social/oauth/{exc.platform}/connect"
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=content,
|
||||
)
|
||||
|
||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
def internal_server_error_handler(request, exception):
|
||||
# 에러 메시지 로깅 (한글 포함 가능)
|
||||
logger.error(f"Internal Server Error: {exception}")
|
||||
return JSONResponse(
|
||||
content={"detail": "Something went wrong..."},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
headers={
|
||||
"X-Error": f"{exception}",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -4,6 +4,11 @@ from redis.asyncio import Redis
|
|||
from app.config import db_settings
|
||||
|
||||
|
||||
_token_blacklist = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=0,
|
||||
)
|
||||
_shipment_verification_codes = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
|
|
@ -11,10 +16,15 @@ _shipment_verification_codes = Redis(
|
|||
decode_responses=True,
|
||||
)
|
||||
|
||||
async def add_jti_to_blacklist(jti: str):
|
||||
await _token_blacklist.set(jti, "blacklisted")
|
||||
|
||||
|
||||
async def is_jti_blacklisted(jti: str) -> bool:
|
||||
return await _token_blacklist.exists(jti)
|
||||
|
||||
async def add_shipment_verification_code(id: UUID, code: int):
|
||||
await _shipment_verification_codes.set(str(id), code)
|
||||
|
||||
|
||||
async def get_shipment_verification_code(id: UUID) -> str:
|
||||
return str(await _shipment_verification_codes.get(str(id)))
|
||||
return str(await _shipment_verification_codes.get(str(id)))
|
||||
|
|
@ -72,37 +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, MarketingIntel # 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
|
||||
from app.sns.models import SNSUploadTask # noqa: F401
|
||||
from app.social.models import SocialUpload # noqa: F401
|
||||
|
||||
# 생성할 테이블 목록
|
||||
tables_to_create = [
|
||||
User.__table__,
|
||||
RefreshToken.__table__,
|
||||
SocialAccount.__table__,
|
||||
Project.__table__,
|
||||
Image.__table__,
|
||||
Lyric.__table__,
|
||||
Song.__table__,
|
||||
SongTimestamp.__table__,
|
||||
Video.__table__,
|
||||
SNSUploadTask.__table__,
|
||||
SocialUpload.__table__,
|
||||
MarketingIntel.__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 의존성용 세션 제너레이터
|
||||
|
|
@ -111,24 +92,22 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
pool = engine.pool
|
||||
|
||||
# 커넥션 풀 상태 로깅 (디버깅용)
|
||||
# logger.debug(
|
||||
# f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
# f"overflow: {pool.overflow()}"
|
||||
# )
|
||||
logger.debug(
|
||||
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
# logger.debug(
|
||||
# f"[get_session] Session acquired in "
|
||||
# f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
# )
|
||||
logger.debug(
|
||||
f"[get_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
import traceback
|
||||
await session.rollback()
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(
|
||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
|
|
@ -136,10 +115,10 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
# logger.debug(
|
||||
# f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
||||
# f"pool_out: {pool.checkedout()}"
|
||||
# )
|
||||
logger.debug(
|
||||
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
|
||||
|
||||
# 백그라운드 태스크용 세션 제너레이터
|
||||
|
|
@ -147,18 +126,18 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
start_time = time.perf_counter()
|
||||
pool = background_engine.pool
|
||||
|
||||
# logger.debug(
|
||||
# f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
# f"overflow: {pool.overflow()}"
|
||||
# )
|
||||
logger.debug(
|
||||
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
)
|
||||
|
||||
async with BackgroundSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
# logger.debug(
|
||||
# f"[get_background_session] Session acquired in "
|
||||
# f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
# )
|
||||
logger.debug(
|
||||
f"[get_background_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
|
|
@ -171,11 +150,11 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
# logger.debug(
|
||||
# f"[get_background_session] RELEASE - "
|
||||
# f"duration: {total_time*1000:.1f}ms, "
|
||||
# f"pool_out: {pool.checkedout()}"
|
||||
# )
|
||||
logger.debug(
|
||||
f"[get_background_session] RELEASE - "
|
||||
f"duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
|
||||
|
||||
# 앱 종료 시 엔진 리소스 정리 함수
|
||||
|
|
|
|||
|
|
@ -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": "프로젝트",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,29 +11,22 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session, AsyncSessionLocal
|
||||
from app.home.models import Image, MarketingIntel
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
from app.home.models import Image
|
||||
from app.home.schemas.home_schema import (
|
||||
AutoCompleteRequest,
|
||||
AccommodationSearchItem,
|
||||
AccommodationSearchResponse,
|
||||
CrawlingRequest,
|
||||
CrawlingResponse,
|
||||
ErrorResponse,
|
||||
ImageUploadResponse,
|
||||
ImageUploadResultItem,
|
||||
ImageUrlItem,
|
||||
MarketingAnalysis,
|
||||
ProcessedInfo,
|
||||
# MarketingAnalysis,
|
||||
)
|
||||
from app.home.services.naver_search import naver_search_client
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.common import generate_task_id
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
|
||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||
from app.utils.prompts.prompts import marketing_prompt
|
||||
from config import MEDIA_ROOT
|
||||
|
||||
|
|
@ -69,49 +62,7 @@ KOREAN_CITIES = [
|
|||
]
|
||||
# fmt: on
|
||||
|
||||
# router = APIRouter(tags=["Home"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search/accommodation",
|
||||
summary="숙박/펜션 자동완성 검색",
|
||||
description="""
|
||||
네이버 지역 검색 API를 이용한 숙박/펜션 자동완성 검색입니다.
|
||||
|
||||
## 요청 파라미터
|
||||
- **query**: 검색어 (필수)
|
||||
|
||||
## 반환 정보
|
||||
- **query**: 검색어
|
||||
- **count**: 검색 결과 수 (최대 10개)
|
||||
- **items**: 검색 결과 목록
|
||||
- **title**: 숙소명 (HTML 태그 포함 가능)
|
||||
- **address**: 지번 주소
|
||||
- **roadAddress**: 도로명 주소
|
||||
""",
|
||||
response_model=AccommodationSearchResponse,
|
||||
responses={
|
||||
200: {"description": "검색 성공", "model": AccommodationSearchResponse},
|
||||
},
|
||||
tags=["Search"],
|
||||
)
|
||||
async def search_accommodation(
|
||||
query: str,
|
||||
) -> AccommodationSearchResponse:
|
||||
"""숙박/펜션 자동완성 검색"""
|
||||
results = await naver_search_client.search_accommodation(
|
||||
query=query,
|
||||
display=10,
|
||||
)
|
||||
|
||||
items = [AccommodationSearchItem(**item) for item in results]
|
||||
|
||||
return AccommodationSearchResponse(
|
||||
query=query,
|
||||
count=len(items),
|
||||
items=items,
|
||||
)
|
||||
router = APIRouter(tags=["Home"])
|
||||
|
||||
|
||||
def _extract_region_from_address(road_address: str | None) -> str:
|
||||
|
|
@ -153,61 +104,18 @@ def _extract_region_from_address(road_address: str | None) -> str:
|
|||
},
|
||||
tags=["Crawling"],
|
||||
)
|
||||
async def crawling(
|
||||
request_body: CrawlingRequest,
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
return await _crawling_logic(request_body.url, session)
|
||||
|
||||
@router.post(
|
||||
"/autocomplete",
|
||||
summary="네이버 자동완성 크롤링",
|
||||
description="""
|
||||
네이버 검색 API 정보를 활용하여 Place ID를 추출한 뒤 자동으로 크롤링합니다.
|
||||
|
||||
## 요청 필드
|
||||
- **title**: 네이버 검색 API Place 결과물 title (필수)
|
||||
- **address**: 네이버 검색 API Place 결과물 지번주소 (필수)
|
||||
- **roadAddress**:네이버 검색 API Place 결과물 도로명주소
|
||||
|
||||
## 반환 정보
|
||||
- **image_list**: 장소 이미지 URL 목록
|
||||
- **image_count**: 이미지 개수
|
||||
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
||||
""",
|
||||
response_model=CrawlingResponse,
|
||||
response_description="크롤링 결과",
|
||||
responses={
|
||||
200: {"description": "크롤링 성공", "model": CrawlingResponse},
|
||||
400: {
|
||||
"description": "잘못된 URL",
|
||||
"model": ErrorResponse,
|
||||
},
|
||||
502: {
|
||||
"description": "크롤링 실패",
|
||||
"model": ErrorResponse,
|
||||
},
|
||||
},
|
||||
tags=["Crawling"],
|
||||
)
|
||||
async def autocomplete_crawling(
|
||||
request_body: AutoCompleteRequest,
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
url = await _autocomplete_logic(request_body.model_dump())
|
||||
return await _crawling_logic(url, session)
|
||||
|
||||
async def _crawling_logic(
|
||||
url:str,
|
||||
session: AsyncSession):
|
||||
async def crawling(request_body: CrawlingRequest):
|
||||
"""네이버 지도 장소 크롤링"""
|
||||
request_start = time.perf_counter()
|
||||
logger.info("[crawling] ========== START ==========")
|
||||
logger.info(f"[crawling] URL: {url[:80]}...")
|
||||
logger.info(f"[crawling] URL: {request_body.url[:80]}...")
|
||||
|
||||
# ========== Step 1: 네이버 지도 크롤링 ==========
|
||||
step1_start = time.perf_counter()
|
||||
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
|
||||
|
||||
try:
|
||||
scraper = NvMapScraper(url)
|
||||
scraper = NvMapScraper(request_body.url)
|
||||
await scraper.scrap()
|
||||
except GraphQLException as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
|
|
@ -286,16 +194,7 @@ async def _crawling_logic(
|
|||
step3_3_start = time.perf_counter()
|
||||
structured_report = await chatgpt_service.generate_structured_output(
|
||||
marketing_prompt, input_marketing_data
|
||||
)
|
||||
marketing_intelligence = MarketingIntel(
|
||||
place_id = scraper.place_id,
|
||||
intel_result = structured_report.model_dump()
|
||||
)
|
||||
session.add(marketing_intelligence)
|
||||
await session.commit()
|
||||
await session.refresh(marketing_intelligence)
|
||||
m_id = marketing_intelligence.id
|
||||
logger.debug(f"[MarketingPrompt] INSERT placeid {marketing_intelligence.place_id}")
|
||||
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||
logger.info(
|
||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||
|
|
@ -318,11 +217,33 @@ async def _crawling_logic(
|
|||
# marketing_analysis = MarketingAnalysis(**parsed)
|
||||
|
||||
logger.debug(
|
||||
f"structured_report = {structured_report.model_dump()}"
|
||||
f"[crawling] structured_report 구조 확인:\n"
|
||||
f"{'='*60}\n"
|
||||
f"[report] type: {type(structured_report.get('report'))}\n"
|
||||
f"{'-'*60}\n"
|
||||
f"{structured_report.get('report')}\n"
|
||||
f"{'='*60}\n"
|
||||
f"[tags] type: {type(structured_report.get('tags'))}\n"
|
||||
f"{'-'*60}\n"
|
||||
f"{structured_report.get('tags')}\n"
|
||||
f"{'='*60}\n"
|
||||
f"[selling_points] type: {type(structured_report.get('selling_points'))}\n"
|
||||
f"{'-'*60}\n"
|
||||
f"{structured_report.get('selling_points')}\n"
|
||||
f"{'='*60}"
|
||||
)
|
||||
|
||||
marketing_analysis = structured_report
|
||||
|
||||
marketing_analysis = MarketingAnalysis(
|
||||
report=structured_report["report"],
|
||||
tags=structured_report["tags"],
|
||||
facilities=list(
|
||||
[sp["keywords"] for sp in structured_report["selling_points"]]
|
||||
), # [json.dumps(structured_report["selling_points"])] # 나중에 Selling Points로 변수와 데이터구조 변경할 것
|
||||
)
|
||||
# Selling Points 구조
|
||||
# print(sp['category'])
|
||||
# print(sp['keywords'])
|
||||
# print(sp['description'])
|
||||
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)"
|
||||
|
|
@ -333,15 +254,6 @@ async def _crawling_logic(
|
|||
f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
except ChatGPTResponseError as e:
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(
|
||||
f"[crawling] Step 3 FAILED - ChatGPT Error: status={e.status}, "
|
||||
f"code={e.error_code}, message={e.error_message} ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
marketing_analysis = None
|
||||
gpt_status = "failed"
|
||||
|
||||
except Exception as e:
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(
|
||||
|
|
@ -350,7 +262,6 @@ async def _crawling_logic(
|
|||
logger.exception("[crawling] Step 3 상세 오류:")
|
||||
# GPT 실패 시에도 크롤링 결과는 반환
|
||||
marketing_analysis = None
|
||||
gpt_status = "failed"
|
||||
else:
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.warning(
|
||||
|
|
@ -370,43 +281,13 @@ async def _crawling_logic(
|
|||
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||
|
||||
return {
|
||||
"status": gpt_status if 'gpt_status' in locals() else "completed",
|
||||
"image_list": scraper.image_link_list,
|
||||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||
"processed_info": processed_info,
|
||||
"marketing_analysis": marketing_analysis,
|
||||
"m_id" : m_id
|
||||
}
|
||||
|
||||
|
||||
async def _autocomplete_logic(autocomplete_item:dict):
|
||||
step1_start = time.perf_counter()
|
||||
try:
|
||||
async with NvMapPwScraper() as pw_scraper:
|
||||
new_url = await pw_scraper.get_place_id_url(autocomplete_item)
|
||||
except Exception as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(
|
||||
f"[crawling] Autocomplete FAILED - 자동완성 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.exception("[crawling] Autocomplete 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="자동완성 place id 추출 실패",
|
||||
)
|
||||
|
||||
if not new_url:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(
|
||||
f"[crawling] Autocomplete FAILED - URL을 찾을 수 없음 ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="해당 장소의 네이버 지도 URL을 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
return new_url
|
||||
|
||||
def _extract_image_name(url: str, index: int) -> str:
|
||||
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
||||
try:
|
||||
|
|
@ -553,7 +434,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 + 바이너리 파일)"""
|
||||
|
|
@ -707,9 +587,6 @@ async def upload_images(
|
|||
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
|
||||
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 요청 방식
|
||||
multipart/form-data 형식으로 전송합니다.
|
||||
|
||||
|
|
@ -736,13 +613,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"
|
||||
```
|
||||
|
|
@ -765,7 +640,6 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|||
responses={
|
||||
200: {"description": "이미지 업로드 성공"},
|
||||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
},
|
||||
tags=["Image-Blob"],
|
||||
openapi_extra={
|
||||
|
|
@ -788,7 +662,6 @@ async def upload_images_blob(
|
|||
default=None,
|
||||
description="이미지 바이너리 파일 목록",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ImageUploadResponse:
|
||||
"""이미지 업로드 (URL + Azure Blob Storage)
|
||||
|
||||
|
|
@ -867,7 +740,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, Any
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, 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(
|
||||
|
|
@ -107,12 +207,6 @@ class Project(Base):
|
|||
comment="상세 지역 정보",
|
||||
)
|
||||
|
||||
marketing_intelligence: Mapped[Optional[str]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
|
|
@ -120,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,
|
||||
|
|
@ -156,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:
|
||||
|
|
@ -180,7 +281,7 @@ class Image(Base):
|
|||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID7)
|
||||
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
|
||||
img_name: 이미지명
|
||||
img_url: 이미지 URL (S3, CDN 등의 경로)
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
|
|
@ -188,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",
|
||||
|
|
@ -208,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(
|
||||
|
|
@ -230,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,
|
||||
|
|
@ -255,66 +347,3 @@ class Image(Base):
|
|||
return (
|
||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||
)
|
||||
|
||||
|
||||
class MarketingIntel(Base):
|
||||
"""
|
||||
마케팅 인텔리전스 결과물 테이블
|
||||
|
||||
마케팅 분석 결과물 저장합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
place_id : 데이터 소스별 식별자
|
||||
intel_result : 마케팅 분석 결과물 json
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
"""
|
||||
|
||||
__tablename__ = "marketing"
|
||||
__table_args__ = (
|
||||
Index("idx_place_id", "place_id"),
|
||||
{
|
||||
"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="고유 식별자",
|
||||
)
|
||||
|
||||
place_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="매장 소스별 고유 식별자",
|
||||
)
|
||||
|
||||
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="마케팅 인텔리전스 결과물",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
task_id_str = (
|
||||
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
||||
)
|
||||
img_name_str = (
|
||||
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
||||
)
|
||||
|
||||
return (
|
||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||
)
|
||||
|
|
@ -1,7 +1,113 @@
|
|||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from app.utils.prompts.schemas import MarketingPromptOutput
|
||||
|
||||
|
||||
class AttributeInfo(BaseModel):
|
||||
"""음악 속성 정보"""
|
||||
|
||||
genre: str = Field(..., description="음악 장르")
|
||||
vocal: str = Field(..., description="보컬 스타일")
|
||||
tempo: str = Field(..., description="템포")
|
||||
mood: str = Field(..., description="분위기")
|
||||
|
||||
|
||||
class GenerateRequestImg(BaseModel):
|
||||
"""이미지 URL 스키마"""
|
||||
|
||||
url: str = Field(..., description="이미지 URL")
|
||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class GenerateRequestInfo(BaseModel):
|
||||
"""생성 요청 정보 스키마 (이미지 제외)"""
|
||||
|
||||
customer_name: str = Field(..., description="고객명/가게명")
|
||||
region: str = Field(..., description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateRequest(GenerateRequestInfo):
|
||||
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
||||
|
||||
이미지 없이 프로젝트 정보만 전달합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GenerateUrlsRequest(GenerateRequestInfo):
|
||||
"""URL 기반 생성 요청 스키마 (JSON body)
|
||||
|
||||
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: list[GenerateRequestImg] = Field(
|
||||
..., description="이미지 URL 목록", min_length=1
|
||||
)
|
||||
|
||||
|
||||
class GenerateUploadResponse(BaseModel):
|
||||
"""파일 업로드 기반 생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
"""생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
|
||||
class CrawlingRequest(BaseModel):
|
||||
"""크롤링 요청 스키마"""
|
||||
|
|
@ -16,61 +122,6 @@ class CrawlingRequest(BaseModel):
|
|||
|
||||
url: str = Field(..., description="네이버 지도 장소 URL")
|
||||
|
||||
class AutoCompleteRequest(BaseModel):
|
||||
"""자동완성 요청 스키마"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
'title': '<b>스테이</b>,<b>머뭄</b>',
|
||||
'address': '전북특별자치도 군산시 신흥동 63-18',
|
||||
'roadAddress': '전북특별자치도 군산시 절골길 18',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
title: str = Field(..., description="네이버 검색 place API Title")
|
||||
address: str = Field(..., description="네이버 검색 place API 지번주소")
|
||||
roadAddress: Optional[str] = Field(None, description="네이버 검색 place API 도로명주소")
|
||||
|
||||
|
||||
class AccommodationSearchItem(BaseModel):
|
||||
"""숙박 검색 결과 아이템"""
|
||||
|
||||
title: str = Field(..., description="숙소명 (HTML 태그 포함 가능)")
|
||||
address: str = Field(..., description="지번 주소")
|
||||
roadAddress: str = Field(default="", description="도로명 주소")
|
||||
|
||||
|
||||
class AccommodationSearchResponse(BaseModel):
|
||||
"""숙박 자동완성 검색 응답"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"query": "스테이 머뭄",
|
||||
"count": 2,
|
||||
"items": [
|
||||
{
|
||||
"title": "<b>스테이</b>,<b>머뭄</b>",
|
||||
"address": "전북특별자치도 군산시 신흥동 63-18",
|
||||
"roadAddress": "전북특별자치도 군산시 절골길 18",
|
||||
},
|
||||
{
|
||||
"title": "머뭄<b>스테이</b>",
|
||||
"address": "전북특별자치도 군산시 비응도동 123",
|
||||
"roadAddress": "전북특별자치도 군산시 비응로 456",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
query: str = Field(..., description="검색어")
|
||||
count: int = Field(..., description="검색 결과 수")
|
||||
items: list[AccommodationSearchItem] = Field(
|
||||
default_factory=list, description="검색 결과 목록"
|
||||
)
|
||||
|
||||
class ProcessedInfo(BaseModel):
|
||||
"""가공된 장소 정보 스키마"""
|
||||
|
|
@ -80,168 +131,25 @@ class ProcessedInfo(BaseModel):
|
|||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||
|
||||
|
||||
# class MarketingAnalysisDetail(BaseModel):
|
||||
# detail_title : str = Field(..., description="디테일 카테고리 이름")
|
||||
# detail_description : str = Field(..., description="해당 항목 설명")
|
||||
class MarketingAnalysis(BaseModel):
|
||||
"""마케팅 분석 결과 스키마"""
|
||||
|
||||
# class MarketingAnalysisReport(BaseModel):
|
||||
# """마케팅 분석 리포트 스키마"""
|
||||
# summary : str = Field(..., description="비즈니스 한 줄 요약")
|
||||
# details : list[MarketingAnalysisDetail] = Field(default_factory=list, description="개별 디테일")
|
||||
|
||||
# class MarketingAnalysis(BaseModel):
|
||||
# """마케팅 분석 결과 스키마"""
|
||||
|
||||
# # report: MarketingAnalysisReport = Field(..., description="마케팅 분석 리포트")
|
||||
# tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
||||
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||
report: str = Field(..., description="마케팅 분석 리포트")
|
||||
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
|
||||
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||
|
||||
|
||||
class CrawlingResponse(BaseModel):
|
||||
"""크롤링 응답 스키마"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"status": "completed",
|
||||
"image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
|
||||
"image_count": 2,
|
||||
"processed_info": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "전북특별자치도 군산시 절골길 18"
|
||||
},
|
||||
"marketing_analysis": {
|
||||
"brand_identity": {
|
||||
"location_feature_analysis": "전북 군산시 절골길 일대는 도시의 편의성과 근교의 한적함을 동시에 누릴 수 있어 ‘조용한 재충전’ 수요에 적합합니다. 군산의 레트로 감성과 주변 관광 동선 결합이 쉬워 1~2박 체류형 여행지로 매력적입니다.",
|
||||
"concept_scalability": "‘머뭄’이라는 네이밍을 ‘잠시 멈춰 머무는 시간’으로 확장해, 느린 체크인·명상/독서 큐레이션·로컬 티/다과 등 체류 경험형 서비스로 고도화가 가능합니다. 로컬 콘텐츠(군산 빵/커피, 근대문화 투어)와 결합해 패키지화하면 재방문 명분을 만들 수 있습니다."
|
||||
},
|
||||
"market_positioning": {
|
||||
"category_definition": "군산 감성 ‘슬로우 스테이’ 프라이빗 숙소",
|
||||
"core_value": "아무것도 하지 않아도 회복되는 ‘멈춤의 시간’"
|
||||
},
|
||||
"target_persona": [
|
||||
{
|
||||
"persona": "번아웃 회복형 직장인 커플: 주말에 조용히 쉬며 리셋을 원하는 2인 여행자",
|
||||
"age": {
|
||||
"min_age": 27,
|
||||
"max_age": 39
|
||||
},
|
||||
"favor_target": [
|
||||
"조용한 동네 분위기",
|
||||
"미니멀/내추럴 인테리어",
|
||||
"편안한 침구와 숙면 환경",
|
||||
"셀프 체크인 선호",
|
||||
"카페·맛집 연계 동선"
|
||||
],
|
||||
"decision_trigger": "‘조용히 쉬는 데 최적화’된 프라이빗함과 숙면 컨디션(침구/동선/소음 차단) 확신"
|
||||
},
|
||||
{
|
||||
"persona": "감성 기록형 친구 여행: 사진과 무드를 위해 공간을 선택하는 2~3인 여성 그룹",
|
||||
"age": {
|
||||
"min_age": 23,
|
||||
"max_age": 34
|
||||
},
|
||||
"favor_target": [
|
||||
"자연광 좋은 공간",
|
||||
"감성 소품/컬러 톤",
|
||||
"포토존(거울/창가/테이블)",
|
||||
"와인·디저트 페어링",
|
||||
"야간 무드 조명"
|
||||
],
|
||||
"decision_trigger": "사진이 ‘그대로 작품’이 되는 포토 스팟과 야간 무드 연출 요소"
|
||||
},
|
||||
{
|
||||
"persona": "로컬 탐험형 소도시 여행자: 군산의 레트로/로컬 콘텐츠를 깊게 즐기는 커플·솔로",
|
||||
"age": {
|
||||
"min_age": 28,
|
||||
"max_age": 45
|
||||
},
|
||||
"favor_target": [
|
||||
"근대문화/레트로 감성",
|
||||
"로컬 맛집·빵집 투어",
|
||||
"동선 효율(차로 이동 용이)",
|
||||
"체크아웃 후 관광 연계",
|
||||
"조용한 밤"
|
||||
],
|
||||
"decision_trigger": "‘군산 로컬 동선’과 결합하기 좋은 위치 + 숙소 자체의 휴식 완성도"
|
||||
}
|
||||
],
|
||||
"selling_points": [
|
||||
{
|
||||
"english_category": "LOCATION",
|
||||
"korean_category": "입지 환경",
|
||||
"description": "군산 감성 동선",
|
||||
"score": 88
|
||||
},
|
||||
{
|
||||
"english_category": "HEALING",
|
||||
"korean_category": "힐링 요소",
|
||||
"description": "멈춤이 되는 쉼",
|
||||
"score": 92
|
||||
},
|
||||
{
|
||||
"english_category": "PRIVACY",
|
||||
"korean_category": "프라이버시",
|
||||
"description": "방해 없는 머뭄",
|
||||
"score": 86
|
||||
},
|
||||
{
|
||||
"english_category": "NIGHT MOOD",
|
||||
"korean_category": "야간 감성",
|
||||
"description": "밤이 예쁜 조명",
|
||||
"score": 84
|
||||
},
|
||||
{
|
||||
"english_category": "PHOTO SPOT",
|
||||
"korean_category": "포토 스팟",
|
||||
"description": "자연광 포토존",
|
||||
"score": 83
|
||||
},
|
||||
{
|
||||
"english_category": "SHORT GETAWAY",
|
||||
"korean_category": "숏브레이크",
|
||||
"description": "주말 리셋 스테이",
|
||||
"score": 89
|
||||
},
|
||||
{
|
||||
"english_category": "HOSPITALITY",
|
||||
"korean_category": "서비스",
|
||||
"description": "세심한 웰컴감",
|
||||
"score": 80
|
||||
}
|
||||
],
|
||||
"target_keywords": [
|
||||
"군산숙소",
|
||||
"군산감성숙소",
|
||||
"전북숙소추천",
|
||||
"군산여행",
|
||||
"커플스테이",
|
||||
"주말여행",
|
||||
"감성스테이",
|
||||
"조용한숙소",
|
||||
"힐링스테이",
|
||||
"스테이머뭄"
|
||||
]
|
||||
},
|
||||
"m_id" : 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
status: str = Field(
|
||||
default="completed",
|
||||
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
||||
)
|
||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||
image_count: int = Field(..., description="이미지 개수")
|
||||
processed_info: Optional[ProcessedInfo] = Field(
|
||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||
)
|
||||
marketing_analysis: Optional[MarketingPromptOutput] = Field(
|
||||
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||
marketing_analysis: Optional[MarketingAnalysis] = Field(
|
||||
None, description="마케팅 분석 결과 (report, tags, facilities)"
|
||||
)
|
||||
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
|
|
@ -265,6 +173,29 @@ class ImageUrlItem(BaseModel):
|
|||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class ImageUploadRequest(BaseModel):
|
||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
||||
|
||||
URL 이미지 목록을 전달합니다.
|
||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: Optional[list[ImageUrlItem]] = Field(
|
||||
None, description="외부 이미지 URL 목록"
|
||||
)
|
||||
|
||||
|
||||
class ImageUploadResultItem(BaseModel):
|
||||
"""업로드된 이미지 결과 아이템"""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
"""
|
||||
네이버 지역 검색 API 클라이언트
|
||||
|
||||
숙박/펜션 자동완성 검색 기능을 제공합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
import aiohttp
|
||||
|
||||
from config import naver_api_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NaverSearchClient:
|
||||
"""
|
||||
네이버 지역 검색 API 클라이언트
|
||||
|
||||
숙박/펜션 카테고리 검색을 위한 클라이언트입니다.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.client_id = naver_api_settings.NAVER_CLIENT_ID
|
||||
self.client_secret = naver_api_settings.NAVER_CLIENT_SECRET
|
||||
self.api_url = naver_api_settings.NAVER_LOCAL_API_URL
|
||||
|
||||
async def search_accommodation(
|
||||
self,
|
||||
query: str,
|
||||
display: int = 5,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
숙박/펜션 검색
|
||||
|
||||
Args:
|
||||
query: 검색어
|
||||
display: 결과 개수 (기본 5개)
|
||||
|
||||
Returns:
|
||||
검색 결과 리스트 (address, roadAddress, title)
|
||||
"""
|
||||
# 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
|
||||
search_query = f"{query} 숙박"
|
||||
|
||||
headers = {
|
||||
"X-Naver-Client-Id": self.client_id,
|
||||
"X-Naver-Client-Secret": self.client_secret,
|
||||
}
|
||||
|
||||
params = {
|
||||
"query": search_query,
|
||||
"display": display,
|
||||
"sort": "random", # 정확도순
|
||||
}
|
||||
|
||||
logger.info(f"[NAVER] 지역 검색 요청 - query: {search_query}")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
self.api_url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
logger.error(
|
||||
f"[NAVER] API 오류 - status: {response.status}, error: {error_text}"
|
||||
)
|
||||
return []
|
||||
|
||||
data = await response.json()
|
||||
items = data.get("items", [])
|
||||
|
||||
# 필요한 필드만 추출
|
||||
results = [
|
||||
{
|
||||
"address": item.get("address", ""),
|
||||
"roadAddress": item.get("roadAddress", ""),
|
||||
"title": item.get("title", ""),
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
|
||||
logger.info(f"[NAVER] 검색 완료 - 결과 수: {len(results)}")
|
||||
return results
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"[NAVER] 네트워크 오류 - {str(e)}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"[NAVER] 예기치 않은 오류 - {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
naver_search_client = NaverSearchClient()
|
||||
|
|
@ -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하여 사용 가능
|
||||
|
|
@ -30,9 +30,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.home.models import Project, MarketingIntel
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
from app.home.models import Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.lyric.schemas.lyric import (
|
||||
GenerateLyricRequest,
|
||||
|
|
@ -48,7 +46,6 @@ from app.utils.pagination import PaginatedResponse, get_paginated
|
|||
|
||||
from app.utils.prompts.prompts import lyric_prompt
|
||||
import traceback as tb
|
||||
import json
|
||||
# 로거 설정
|
||||
logger = get_logger("lyric")
|
||||
|
||||
|
|
@ -175,9 +172,6 @@ async def get_lyric_by_task_id(
|
|||
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
||||
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 요청 필드
|
||||
- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수)
|
||||
- **customer_name**: 고객명/가게명 (필수)
|
||||
|
|
@ -196,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"
|
||||
}'
|
||||
}
|
||||
```
|
||||
|
||||
## 응답 예시
|
||||
|
|
@ -224,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:
|
||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||
|
|
@ -279,51 +269,34 @@ async def generate_lyric(
|
|||
Full verse flow, immersive mood
|
||||
"""
|
||||
}
|
||||
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
||||
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
||||
|
||||
|
||||
lyric_input_data = {
|
||||
"customer_name" : request_body.customer_name,
|
||||
"region" : request_body.region,
|
||||
"detail_region_info" : request_body.detail_region_info or "",
|
||||
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
||||
"marketing_intelligence_summary" : None, # task_idx 변경 후 marketing intelligence summary DB에 저장하고 사용할 필요가 있음
|
||||
"language" : request_body.language,
|
||||
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||
}
|
||||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 2: Project 조회 또는 생성 ==========
|
||||
# ========== Step 2: Project 테이블에 데이터 저장 ==========
|
||||
step2_start = time.perf_counter()
|
||||
logger.debug(f"[generate_lyric] Step 2: Project 조회 또는 생성...")
|
||||
logger.debug(f"[generate_lyric] Step 2: Project 저장...")
|
||||
|
||||
# 기존 Project가 있는지 확인 (재생성 시 재사용)
|
||||
existing_project_result = await session.execute(
|
||||
select(Project).where(Project.task_id == task_id).limit(1)
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
task_id=task_id,
|
||||
detail_region_info=request_body.detail_region_info,
|
||||
language=request_body.language,
|
||||
)
|
||||
project = existing_project_result.scalar_one_or_none()
|
||||
|
||||
if project:
|
||||
# 기존 Project 재사용 (재생성 케이스)
|
||||
logger.info(f"[generate_lyric] 기존 Project 재사용 - project_id: {project.id}, task_id: {task_id}")
|
||||
else:
|
||||
# 새 Project 생성 (최초 생성 케이스)
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
task_id=task_id,
|
||||
detail_region_info=request_body.detail_region_info,
|
||||
language=request_body.language,
|
||||
user_uuid=current_user.user_uuid,
|
||||
marketing_intelligence = request_body.m_id
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
logger.info(f"[generate_lyric] 새 Project 생성 - project_id: {project.id}, task_id: {task_id}")
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
|
||||
|
|
@ -357,7 +330,6 @@ async def generate_lyric(
|
|||
task_id=task_id,
|
||||
prompt=lyric_prompt,
|
||||
lyric_input_data=lyric_input_data,
|
||||
lyric_id=lyric.id,
|
||||
)
|
||||
|
||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||
|
|
@ -401,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로 가사 생성 작업 상태를 조회합니다."""
|
||||
|
|
@ -432,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)
|
||||
|
|
@ -453,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개씩 조회
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
|
@ -475,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]:
|
||||
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
|
||||
|
|
@ -503,9 +456,6 @@ async def list_lyrics(
|
|||
description="""
|
||||
task_id로 생성된 가사의 상세 정보를 조회합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 반환 정보
|
||||
- **id**: 가사 ID
|
||||
- **task_id**: 작업 고유 식별자
|
||||
|
|
@ -515,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,
|
||||
|
|
|
|||
|
|
@ -41,8 +41,7 @@ class GenerateLyricRequest(BaseModel):
|
|||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean",
|
||||
"m_id" : 1
|
||||
"language": "Korean"
|
||||
}
|
||||
"""
|
||||
|
||||
|
|
@ -54,7 +53,6 @@ class GenerateLyricRequest(BaseModel):
|
|||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean",
|
||||
"m_id" : 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -69,7 +67,6 @@ class GenerateLyricRequest(BaseModel):
|
|||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||
|
||||
|
||||
class GenerateLyricResponse(BaseModel):
|
||||
|
|
@ -111,33 +108,15 @@ class LyricStatusResponse(BaseModel):
|
|||
Usage:
|
||||
GET /lyric/status/{task_id}
|
||||
Returns the current processing status of a lyric generation task.
|
||||
|
||||
Status Values:
|
||||
- processing: 가사 생성 진행 중
|
||||
- completed: 가사 생성 완료
|
||||
- failed: ChatGPT API 오류 또는 생성 실패
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"summary": "성공",
|
||||
"value": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "completed",
|
||||
"message": "가사 생성이 완료되었습니다.",
|
||||
}
|
||||
},
|
||||
{
|
||||
"summary": "실패",
|
||||
"value": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "failed",
|
||||
"message": "가사 생성에 실패했습니다.",
|
||||
}
|
||||
}
|
||||
]
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "completed",
|
||||
"message": "가사 생성이 완료되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -152,46 +131,26 @@ class LyricDetailResponse(BaseModel):
|
|||
Usage:
|
||||
GET /lyric/{task_id}
|
||||
Returns the generated lyric content for a specific task.
|
||||
|
||||
Note:
|
||||
- status가 "failed"인 경우 lyric_result에 에러 메시지가 저장됩니다.
|
||||
- 에러 메시지 형식: "ChatGPT Error: {message}" 또는 "Error: {message}"
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"summary": "성공",
|
||||
"value": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"project_id": 1,
|
||||
"status": "completed",
|
||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
},
|
||||
{
|
||||
"summary": "실패",
|
||||
"value": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"project_id": 1,
|
||||
"status": "failed",
|
||||
"lyric_result": "ChatGPT Error: Response incomplete: max_output_tokens",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
}
|
||||
]
|
||||
"example": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"project_id": 1,
|
||||
"status": "completed",
|
||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: int = Field(..., description="가사 ID")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
project_id: int = Field(..., description="프로젝트 ID")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
||||
status: str = Field(..., description="처리 상태")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.lyric.models import Lyric
|
||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.prompts.prompts import Prompt
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
|
|
@ -23,7 +23,6 @@ async def _update_lyric_status(
|
|||
task_id: str,
|
||||
status: str,
|
||||
result: str | None = None,
|
||||
lyric_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
||||
|
||||
|
|
@ -31,26 +30,18 @@ async def _update_lyric_status(
|
|||
task_id: 프로젝트 task_id
|
||||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
result: 가사 결과 또는 에러 메시지
|
||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
if lyric_id:
|
||||
# lyric_id로 특정 레코드 조회 (재생성 시에도 정확한 레코드 업데이트)
|
||||
query_result = await session.execute(
|
||||
select(Lyric).where(Lyric.id == lyric_id)
|
||||
)
|
||||
else:
|
||||
# 기존 방식: task_id로 최신 레코드 조회
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
query_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lyric = query_result.scalar_one_or_none()
|
||||
|
||||
if lyric:
|
||||
|
|
@ -58,33 +49,31 @@ async def _update_lyric_status(
|
|||
if result is not None:
|
||||
lyric.lyric_result = result
|
||||
await session.commit()
|
||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
|
||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}, lyric_id: {lyric_id}")
|
||||
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
|
||||
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
|
||||
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def generate_lyric_background(
|
||||
task_id: str,
|
||||
prompt: Prompt,
|
||||
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
|
||||
lyric_id: int | None = None,
|
||||
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
|
||||
) -> None:
|
||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
prompt: ChatGPT에 전달할 프롬프트
|
||||
lyric_input_data: 프롬프트 입력 데이터
|
||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||
language: 가사 언어
|
||||
"""
|
||||
import time
|
||||
|
||||
|
|
@ -119,7 +108,7 @@ async def generate_lyric_background(
|
|||
|
||||
#result = await service.generate(prompt=prompt)
|
||||
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
|
||||
result = result_response.lyric
|
||||
result = result_response['lyric']
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||
|
||||
|
|
@ -127,7 +116,7 @@ async def generate_lyric_background(
|
|||
step3_start = time.perf_counter()
|
||||
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
||||
|
||||
await _update_lyric_status(task_id, "completed", result, lyric_id)
|
||||
await _update_lyric_status(task_id, "completed", result)
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
||||
|
|
@ -141,20 +130,12 @@ async def generate_lyric_background(
|
|||
logger.debug(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
|
||||
|
||||
except ChatGPTResponseError as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(
|
||||
f"[generate_lyric_background] ChatGPT ERROR - task_id: {task_id}, "
|
||||
f"status: {e.status}, code: {e.error_code}, message: {e.error_message} ({elapsed:.1f}ms)"
|
||||
)
|
||||
await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}", lyric_id)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}", lyric_id)
|
||||
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
"""
|
||||
SNS API 라우터
|
||||
|
||||
Instagram 업로드 관련 엔드포인트를 제공합니다.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import Platform, SocialAccount, User
|
||||
from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
|
||||
from app.utils.logger import get_logger
|
||||
from app.video.models import Video
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SNS 예외 클래스 정의
|
||||
# =============================================================================
|
||||
class SNSException(HTTPException):
|
||||
"""SNS 관련 기본 예외"""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str):
|
||||
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
class SocialAccountNotFoundError(SNSException):
|
||||
"""소셜 계정 없음"""
|
||||
|
||||
def __init__(self, message: str = "연동된 소셜 계정을 찾을 수 없습니다."):
|
||||
super().__init__(status.HTTP_404_NOT_FOUND, "SOCIAL_ACCOUNT_NOT_FOUND", message)
|
||||
|
||||
|
||||
class VideoNotFoundError(SNSException):
|
||||
"""비디오 없음"""
|
||||
|
||||
def __init__(self, message: str = "해당 작업 ID에 대한 비디오를 찾을 수 없습니다."):
|
||||
super().__init__(status.HTTP_404_NOT_FOUND, "VIDEO_NOT_FOUND", message)
|
||||
|
||||
|
||||
class VideoUrlNotReadyError(SNSException):
|
||||
"""비디오 URL 미준비"""
|
||||
|
||||
def __init__(self, message: str = "비디오가 아직 준비되지 않았습니다."):
|
||||
super().__init__(status.HTTP_400_BAD_REQUEST, "VIDEO_URL_NOT_READY", message)
|
||||
|
||||
|
||||
class InstagramUploadError(SNSException):
|
||||
"""Instagram 업로드 실패"""
|
||||
|
||||
def __init__(self, message: str = "Instagram 업로드에 실패했습니다."):
|
||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_UPLOAD_ERROR", message)
|
||||
|
||||
|
||||
class InstagramRateLimitError(SNSException):
|
||||
"""Instagram API Rate Limit"""
|
||||
|
||||
def __init__(self, message: str = "Instagram API 호출 제한을 초과했습니다.", retry_after: int = 60):
|
||||
super().__init__(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"INSTAGRAM_RATE_LIMIT",
|
||||
f"{message} {retry_after}초 후 다시 시도해주세요.",
|
||||
)
|
||||
|
||||
|
||||
class InstagramAuthError(SNSException):
|
||||
"""Instagram 인증 오류"""
|
||||
|
||||
def __init__(self, message: str = "Instagram 인증에 실패했습니다. 계정을 다시 연동해주세요."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "INSTAGRAM_AUTH_ERROR", message)
|
||||
|
||||
|
||||
class InstagramContainerTimeoutError(SNSException):
|
||||
"""Instagram 미디어 처리 타임아웃"""
|
||||
|
||||
def __init__(self, message: str = "Instagram 미디어 처리 시간이 초과되었습니다."):
|
||||
super().__init__(status.HTTP_504_GATEWAY_TIMEOUT, "INSTAGRAM_CONTAINER_TIMEOUT", message)
|
||||
|
||||
|
||||
class InstagramContainerError(SNSException):
|
||||
"""Instagram 미디어 컨테이너 오류"""
|
||||
|
||||
def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
|
||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sns", tags=["SNS"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instagram/upload/{task_id}",
|
||||
summary="Instagram 비디오 업로드",
|
||||
description="""
|
||||
## 개요
|
||||
task_id에 해당하는 비디오를 Instagram에 업로드합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **task_id**: 비디오 생성 작업 고유 식별자
|
||||
|
||||
## 요청 본문
|
||||
- **caption**: 게시물 캡션 (선택, 최대 2200자)
|
||||
- **share_to_feed**: 피드에 공유 여부 (기본값: true)
|
||||
|
||||
## 인증
|
||||
- Bearer 토큰 필요 (Authorization: Bearer <token>)
|
||||
- 사용자의 Instagram 계정이 연동되어 있어야 합니다.
|
||||
|
||||
## 반환 정보
|
||||
- **task_id**: 작업 고유 식별자
|
||||
- **state**: 업로드 상태 (completed, failed)
|
||||
- **message**: 상태 메시지
|
||||
- **media_id**: Instagram 미디어 ID (성공 시)
|
||||
- **permalink**: Instagram 게시물 URL (성공 시)
|
||||
- **error**: 에러 메시지 (실패 시)
|
||||
""",
|
||||
response_model=InstagramUploadResponse,
|
||||
responses={
|
||||
200: {"description": "업로드 성공"},
|
||||
400: {"description": "비디오 URL 미준비"},
|
||||
401: {"description": "인증 실패"},
|
||||
404: {"description": "비디오 또는 소셜 계정 없음"},
|
||||
429: {"description": "Instagram API Rate Limit"},
|
||||
500: {"description": "업로드 실패"},
|
||||
504: {"description": "타임아웃"},
|
||||
},
|
||||
)
|
||||
async def upload_to_instagram(
|
||||
task_id: str,
|
||||
request: InstagramUploadRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InstagramUploadResponse:
|
||||
"""Instagram에 비디오를 업로드합니다."""
|
||||
logger.info(f"[upload_to_instagram] START - task_id: {task_id}, user_uuid: {current_user.user_uuid}")
|
||||
|
||||
# Step 1: 사용자의 Instagram 소셜 계정 조회
|
||||
social_account_result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == current_user.user_uuid,
|
||||
SocialAccount.platform == Platform.INSTAGRAM,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
social_account = social_account_result.scalar_one_or_none()
|
||||
|
||||
if social_account is None:
|
||||
logger.warning(f"[upload_to_instagram] Instagram 계정 없음 - user_uuid: {current_user.user_uuid}")
|
||||
raise SocialAccountNotFoundError("연동된 Instagram 계정을 찾을 수 없습니다.")
|
||||
|
||||
logger.info(f"[upload_to_instagram] 소셜 계정 확인 - social_account_id: {social_account.id}")
|
||||
|
||||
# Step 2: task_id로 비디오 조회 (가장 최근 것)
|
||||
video_result = await session.execute(
|
||||
select(Video)
|
||||
.where(
|
||||
Video.task_id == task_id,
|
||||
Video.is_deleted == False, # noqa: E712
|
||||
)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
|
||||
if video is None:
|
||||
logger.warning(f"[upload_to_instagram] 비디오 없음 - task_id: {task_id}")
|
||||
raise VideoNotFoundError(f"task_id '{task_id}'에 해당하는 비디오를 찾을 수 없습니다.")
|
||||
|
||||
if video.result_movie_url is None:
|
||||
logger.warning(f"[upload_to_instagram] 비디오 URL 미준비 - task_id: {task_id}, status: {video.status}")
|
||||
raise VideoUrlNotReadyError("비디오가 아직 처리 중입니다. 잠시 후 다시 시도해주세요.")
|
||||
|
||||
logger.info(f"[upload_to_instagram] 비디오 확인 - video_id: {video.id}, url: {video.result_movie_url[:50]}...")
|
||||
|
||||
# Step 3: Instagram 업로드
|
||||
try:
|
||||
async with InstagramClient(access_token=social_account.access_token) as client:
|
||||
# 접속 테스트 (계정 ID 조회)
|
||||
await client.get_account_id()
|
||||
logger.info("[upload_to_instagram] Instagram 접속 확인 완료")
|
||||
|
||||
# 비디오 업로드
|
||||
media = await client.publish_video(
|
||||
video_url=video.result_movie_url,
|
||||
caption=request.caption,
|
||||
share_to_feed=request.share_to_feed,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[upload_to_instagram] SUCCESS - task_id: {task_id}, "
|
||||
f"media_id: {media.id}, permalink: {media.permalink}"
|
||||
)
|
||||
|
||||
return InstagramUploadResponse(
|
||||
task_id=task_id,
|
||||
state="completed",
|
||||
message="Instagram 업로드 완료",
|
||||
media_id=media.id,
|
||||
permalink=media.permalink,
|
||||
error=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_state, message, extra_info = parse_instagram_error(e)
|
||||
logger.error(f"[upload_to_instagram] FAILED - task_id: {task_id}, error_state: {error_state}, message: {message}")
|
||||
|
||||
match error_state:
|
||||
case ErrorState.RATE_LIMIT:
|
||||
retry_after = extra_info.get("retry_after", 60)
|
||||
raise InstagramRateLimitError(retry_after=retry_after)
|
||||
|
||||
case ErrorState.AUTH_ERROR:
|
||||
raise InstagramAuthError()
|
||||
|
||||
case ErrorState.CONTAINER_TIMEOUT:
|
||||
raise InstagramContainerTimeoutError()
|
||||
|
||||
case ErrorState.CONTAINER_ERROR:
|
||||
status = extra_info.get("status", "UNKNOWN")
|
||||
raise InstagramContainerError(f"미디어 처리 실패: {status}")
|
||||
|
||||
case _:
|
||||
raise InstagramUploadError(f"Instagram 업로드 실패: {message}")
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.sns.models import SNSUploadTask
|
||||
|
||||
|
||||
class SNSUploadTaskAdmin(ModelView, model=SNSUploadTask):
|
||||
name = "SNS 업로드 작업"
|
||||
name_plural = "SNS 업로드 작업 목록"
|
||||
icon = "fa-solid fa-share-from-square"
|
||||
category = "SNS 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"task_id",
|
||||
"social_account_id",
|
||||
"is_scheduled",
|
||||
"status",
|
||||
"scheduled_at",
|
||||
"uploaded_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"task_id",
|
||||
"social_account_id",
|
||||
"is_scheduled",
|
||||
"scheduled_at",
|
||||
"url",
|
||||
"caption",
|
||||
"status",
|
||||
"uploaded_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at", "user", "social_account"]
|
||||
|
||||
column_searchable_list = [
|
||||
SNSUploadTask.user_uuid,
|
||||
SNSUploadTask.task_id,
|
||||
SNSUploadTask.status,
|
||||
]
|
||||
|
||||
column_default_sort = (SNSUploadTask.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
SNSUploadTask.id,
|
||||
SNSUploadTask.user_uuid,
|
||||
SNSUploadTask.social_account_id,
|
||||
SNSUploadTask.is_scheduled,
|
||||
SNSUploadTask.status,
|
||||
SNSUploadTask.scheduled_at,
|
||||
SNSUploadTask.uploaded_at,
|
||||
SNSUploadTask.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_uuid": "사용자 UUID",
|
||||
"task_id": "작업 ID",
|
||||
"social_account_id": "소셜 계정 ID",
|
||||
"is_scheduled": "예약 여부",
|
||||
"scheduled_at": "예약 일시",
|
||||
"url": "미디어 URL",
|
||||
"caption": "캡션",
|
||||
"status": "상태",
|
||||
"uploaded_at": "업로드 일시",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
"""
|
||||
SNS 모듈 SQLAlchemy 모델 정의
|
||||
|
||||
SNS 업로드 작업 관리 모델입니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.user.models import SocialAccount, User
|
||||
|
||||
|
||||
class SNSUploadTask(Base):
|
||||
"""
|
||||
SNS 업로드 작업 테이블
|
||||
|
||||
SNS 플랫폼에 콘텐츠를 업로드하는 작업을 관리합니다.
|
||||
즉시 업로드 또는 예약 업로드를 지원합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||
task_id: 외부 작업 식별자 (비디오 생성 작업 등)
|
||||
is_scheduled: 예약 작업 여부 (True: 예약, False: 즉시)
|
||||
scheduled_at: 예약 발행 일시 (분 단위까지)
|
||||
social_account_id: 소셜 계정 외래키 (SocialAccount.id 참조)
|
||||
url: 업로드할 미디어 URL
|
||||
caption: 게시물 캡션/설명
|
||||
status: 발행 상태 (pending: 예약 대기, completed: 완료, error: 에러)
|
||||
uploaded_at: 실제 업로드 완료 일시
|
||||
created_at: 작업 생성 일시
|
||||
|
||||
발행 상태 (status):
|
||||
- pending: 예약 대기 중 (예약 작업이거나 처리 전)
|
||||
- processing: 처리 중
|
||||
- completed: 발행 완료
|
||||
- error: 에러 발생
|
||||
|
||||
Relationships:
|
||||
user: 작업 소유 사용자 (User 테이블 참조)
|
||||
social_account: 발행 대상 소셜 계정 (SocialAccount 테이블 참조)
|
||||
"""
|
||||
|
||||
__tablename__ = "sns_upload_task"
|
||||
__table_args__ = (
|
||||
Index("idx_sns_upload_task_user_uuid", "user_uuid"),
|
||||
Index("idx_sns_upload_task_task_id", "task_id"),
|
||||
Index("idx_sns_upload_task_social_account_id", "social_account_id"),
|
||||
Index("idx_sns_upload_task_status", "status"),
|
||||
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
|
||||
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
|
||||
Index("idx_sns_upload_task_created_at", "created_at"),
|
||||
{
|
||||
"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_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 UUID (User.user_uuid 참조)",
|
||||
)
|
||||
|
||||
task_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="외부 작업 식별자 (비디오 생성 작업 ID 등)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 예약 설정
|
||||
# ==========================================================================
|
||||
is_scheduled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="예약 작업 여부 (True: 예약 발행, False: 즉시 발행)",
|
||||
)
|
||||
|
||||
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="예약 발행 일시 (분 단위까지 지정)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 소셜 계정 연결
|
||||
# ==========================================================================
|
||||
social_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("social_account.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="소셜 계정 외래키 (SocialAccount.id 참조)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 업로드 콘텐츠
|
||||
# ==========================================================================
|
||||
url: Mapped[str] = mapped_column(
|
||||
String(2048),
|
||||
nullable=False,
|
||||
comment="업로드할 미디어 URL",
|
||||
)
|
||||
|
||||
caption: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="게시물 캡션/설명",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 발행 상태
|
||||
# ==========================================================================
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
comment="발행 상태 (pending: 예약 대기, processing: 처리 중, completed: 완료, error: 에러)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 시간 정보
|
||||
# ==========================================================================
|
||||
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="실제 업로드 완료 일시",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="작업 생성 일시",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Relationships
|
||||
# ==========================================================================
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[user_uuid],
|
||||
primaryjoin="SNSUploadTask.user_uuid == User.user_uuid",
|
||||
)
|
||||
|
||||
social_account: Mapped["SocialAccount"] = relationship(
|
||||
"SocialAccount",
|
||||
foreign_keys=[social_account_id],
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<SNSUploadTask("
|
||||
f"id={self.id}, "
|
||||
f"user_uuid='{self.user_uuid}', "
|
||||
f"social_account_id={self.social_account_id}, "
|
||||
f"status='{self.status}', "
|
||||
f"is_scheduled={self.is_scheduled}"
|
||||
f")>"
|
||||
)
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
"""
|
||||
SNS API Schemas
|
||||
|
||||
Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class InstagramUploadRequest(BaseModel):
|
||||
"""Instagram 업로드 요청 스키마
|
||||
|
||||
Usage:
|
||||
POST /sns/instagram/upload/{task_id}
|
||||
Instagram에 비디오를 업로드합니다.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"caption": "Test video from Instagram POC #test",
|
||||
"share_to_feed": true
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"caption": "Test video from Instagram POC #test",
|
||||
"share_to_feed": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
caption: str = Field(
|
||||
default="",
|
||||
description="게시물 캡션",
|
||||
max_length=2200,
|
||||
)
|
||||
share_to_feed: bool = Field(
|
||||
default=True,
|
||||
description="피드에 공유 여부",
|
||||
)
|
||||
|
||||
|
||||
class InstagramUploadResponse(BaseModel):
|
||||
"""Instagram 업로드 응답 스키마
|
||||
|
||||
Usage:
|
||||
POST /sns/instagram/upload/{task_id}
|
||||
Instagram 업로드 작업의 결과를 반환합니다.
|
||||
|
||||
Example Response (성공):
|
||||
{
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"state": "completed",
|
||||
"message": "Instagram 업로드 완료",
|
||||
"media_id": "17841405822304914",
|
||||
"permalink": "https://www.instagram.com/p/ABC123/",
|
||||
"error": null
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"state": "completed",
|
||||
"message": "Instagram 업로드 완료",
|
||||
"media_id": "17841405822304914",
|
||||
"permalink": "https://www.instagram.com/p/ABC123/",
|
||||
"error": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
state: str = Field(..., description="업로드 상태 (pending, processing, completed, failed)")
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
media_id: Optional[str] = Field(default=None, description="Instagram 미디어 ID (성공 시)")
|
||||
permalink: Optional[str] = Field(default=None, description="Instagram 게시물 URL (성공 시)")
|
||||
error: Optional[str] = Field(default=None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class Media(BaseModel):
|
||||
"""Instagram 미디어 정보"""
|
||||
|
||||
id: str
|
||||
media_type: Optional[str] = None
|
||||
media_url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None
|
||||
caption: Optional[str] = None
|
||||
timestamp: Optional[datetime] = None
|
||||
permalink: Optional[str] = None
|
||||
like_count: int = 0
|
||||
comments_count: int = 0
|
||||
children: Optional[list["Media"]] = None
|
||||
|
||||
|
||||
class MediaContainer(BaseModel):
|
||||
"""미디어 컨테이너 상태"""
|
||||
|
||||
id: str
|
||||
status_code: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_finished(self) -> bool:
|
||||
return self.status_code == "FINISHED"
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
return self.status_code == "ERROR"
|
||||
|
||||
@property
|
||||
def is_in_progress(self) -> bool:
|
||||
return self.status_code == "IN_PROGRESS"
|
||||
|
||||
|
||||
class APIError(BaseModel):
|
||||
"""API 에러 응답"""
|
||||
|
||||
message: str
|
||||
type: Optional[str] = None
|
||||
code: Optional[int] = None
|
||||
error_subcode: Optional[int] = None
|
||||
fbtrace_id: Optional[str] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""에러 응답 래퍼"""
|
||||
|
||||
error: APIError
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
"""
|
||||
Social Media Integration Module
|
||||
|
||||
소셜 미디어 플랫폼 연동 및 영상 업로드 기능을 제공합니다.
|
||||
|
||||
지원 플랫폼:
|
||||
- YouTube (구현됨)
|
||||
- Instagram (추후 구현)
|
||||
- Facebook (추후 구현)
|
||||
- TikTok (추후 구현)
|
||||
"""
|
||||
|
||||
from app.social.constants import SocialPlatform, UploadStatus
|
||||
|
||||
__all__ = ["SocialPlatform", "UploadStatus"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Social API Module
|
||||
"""
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Social API Routers
|
||||
"""
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"""
|
||||
Social API Routers v1
|
||||
"""
|
||||
|
||||
from app.social.api.routers.v1.oauth import router as oauth_router
|
||||
from app.social.api.routers.v1.upload import router as upload_router
|
||||
from app.social.api.routers.v1.seo import router as seo_router
|
||||
__all__ = ["oauth_router", "upload_router", "seo_router"]
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
"""
|
||||
소셜 OAuth API 라우터
|
||||
|
||||
소셜 미디어 계정 연동 관련 엔드포인트를 제공합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import social_oauth_settings
|
||||
from app.database.session import get_session
|
||||
from app.social.constants import SocialPlatform
|
||||
from app.social.schemas import (
|
||||
MessageResponse,
|
||||
SocialAccountListResponse,
|
||||
SocialAccountResponse,
|
||||
SocialConnectResponse,
|
||||
)
|
||||
from app.social.services import social_account_service
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/oauth", tags=["Social OAuth"])
|
||||
|
||||
|
||||
def _build_redirect_url(is_success: bool, params: dict) -> str:
|
||||
"""OAuth 리다이렉트 URL 생성"""
|
||||
base_url = social_oauth_settings.OAUTH_FRONTEND_URL.rstrip("/")
|
||||
path = (
|
||||
social_oauth_settings.OAUTH_SUCCESS_PATH
|
||||
if is_success
|
||||
else social_oauth_settings.OAUTH_ERROR_PATH
|
||||
)
|
||||
return f"{base_url}{path}?{urlencode(params)}"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{platform}/connect",
|
||||
response_model=SocialConnectResponse,
|
||||
summary="소셜 계정 연동 시작",
|
||||
description="""
|
||||
소셜 미디어 계정 연동을 시작합니다.
|
||||
|
||||
## 지원 플랫폼
|
||||
- **youtube**: YouTube (Google OAuth)
|
||||
- instagram, facebook, tiktok: 추후 지원 예정
|
||||
|
||||
## 플로우
|
||||
1. 이 엔드포인트를 호출하여 `auth_url`과 `state`를 받음
|
||||
2. 프론트엔드에서 `auth_url`로 사용자를 리다이렉트
|
||||
3. 사용자가 플랫폼에서 권한 승인
|
||||
4. 플랫폼이 `/callback` 엔드포인트로 리다이렉트
|
||||
5. 연동 완료 후 프론트엔드로 리다이렉트
|
||||
""",
|
||||
)
|
||||
async def start_connect(
|
||||
platform: SocialPlatform,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> SocialConnectResponse:
|
||||
"""
|
||||
소셜 계정 연동 시작
|
||||
|
||||
OAuth 인증 URL을 생성하고 state 토큰을 반환합니다.
|
||||
프론트엔드에서 반환된 auth_url로 사용자를 리다이렉트하면 됩니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[OAUTH_API] 소셜 연동 시작 - "
|
||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
return await social_account_service.start_connect(
|
||||
user_uuid=current_user.user_uuid,
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{platform}/callback",
|
||||
summary="OAuth 콜백",
|
||||
description="""
|
||||
소셜 플랫폼의 OAuth 콜백을 처리합니다.
|
||||
|
||||
이 엔드포인트는 소셜 플랫폼에서 직접 호출되며,
|
||||
사용자를 프론트엔드로 리다이렉트합니다.
|
||||
""",
|
||||
)
|
||||
async def oauth_callback(
|
||||
platform: SocialPlatform,
|
||||
code: str | None = Query(None, description="OAuth 인가 코드"),
|
||||
state: str | None = Query(None, description="CSRF 방지용 state 토큰"),
|
||||
error: str | None = Query(None, description="OAuth 에러 코드 (사용자 취소 등)"),
|
||||
error_description: str | None = Query(None, description="OAuth 에러 설명"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RedirectResponse:
|
||||
"""
|
||||
OAuth 콜백 처리
|
||||
|
||||
소셜 플랫폼에서 리다이렉트된 후 호출됩니다.
|
||||
인가 코드로 토큰을 교환하고 계정을 연동합니다.
|
||||
"""
|
||||
# 사용자가 취소하거나 에러가 발생한 경우
|
||||
if error:
|
||||
logger.info(
|
||||
f"[OAUTH_API] OAuth 취소/에러 - "
|
||||
f"platform: {platform.value}, error: {error}, description: {error_description}"
|
||||
)
|
||||
|
||||
# 에러 메시지 생성
|
||||
if error == "access_denied":
|
||||
error_message = "사용자가 연동을 취소했습니다."
|
||||
else:
|
||||
error_message = error_description or error
|
||||
|
||||
redirect_url = _build_redirect_url(
|
||||
is_success=False,
|
||||
params={
|
||||
"platform": platform.value,
|
||||
"error": error_message,
|
||||
"cancelled": "true" if error == "access_denied" else "false",
|
||||
},
|
||||
)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
# code나 state가 없는 경우
|
||||
if not code or not state:
|
||||
logger.warning(
|
||||
f"[OAUTH_API] OAuth 콜백 파라미터 누락 - "
|
||||
f"platform: {platform.value}, code: {bool(code)}, state: {bool(state)}"
|
||||
)
|
||||
redirect_url = _build_redirect_url(
|
||||
is_success=False,
|
||||
params={
|
||||
"platform": platform.value,
|
||||
"error": "잘못된 요청입니다. 다시 시도해주세요.",
|
||||
},
|
||||
)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
logger.info(
|
||||
f"[OAUTH_API] OAuth 콜백 수신 - "
|
||||
f"platform: {platform.value}, code: {code[:20]}..."
|
||||
)
|
||||
|
||||
try:
|
||||
account = await social_account_service.handle_callback(
|
||||
code=code,
|
||||
state=state,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 성공 시 프론트엔드로 리다이렉트 (계정 정보 포함)
|
||||
redirect_url = _build_redirect_url(
|
||||
is_success=True,
|
||||
params={
|
||||
"platform": platform.value,
|
||||
"account_id": account.id,
|
||||
"channel_name": account.display_name or account.platform_username or "",
|
||||
"profile_image": account.profile_image_url or "",
|
||||
},
|
||||
)
|
||||
logger.info(f"[OAUTH_API] 연동 성공, 리다이렉트 - url: {redirect_url}")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[OAUTH_API] OAuth 콜백 처리 실패 - error: {e}")
|
||||
# 실패 시 에러 페이지로 리다이렉트
|
||||
redirect_url = _build_redirect_url(
|
||||
is_success=False,
|
||||
params={
|
||||
"platform": platform.value,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/accounts",
|
||||
response_model=SocialAccountListResponse,
|
||||
summary="연동된 소셜 계정 목록 조회",
|
||||
description="현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.",
|
||||
)
|
||||
async def get_connected_accounts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountListResponse:
|
||||
"""
|
||||
연동된 소셜 계정 목록 조회
|
||||
|
||||
현재 로그인한 사용자가 연동한 모든 소셜 계정을 조회합니다.
|
||||
"""
|
||||
logger.info(f"[OAUTH_API] 연동 계정 목록 조회 - user_uuid: {current_user.user_uuid}")
|
||||
|
||||
accounts = await social_account_service.get_connected_accounts(
|
||||
user_uuid=current_user.user_uuid,
|
||||
session=session,
|
||||
)
|
||||
|
||||
return SocialAccountListResponse(
|
||||
accounts=accounts,
|
||||
total=len(accounts),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/accounts/{platform}",
|
||||
response_model=SocialAccountResponse,
|
||||
summary="특정 플랫폼 연동 계정 조회",
|
||||
description="특정 플랫폼에 연동된 계정 정보를 반환합니다.",
|
||||
)
|
||||
async def get_account_by_platform(
|
||||
platform: SocialPlatform,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountResponse:
|
||||
"""
|
||||
특정 플랫폼 연동 계정 조회
|
||||
"""
|
||||
logger.info(
|
||||
f"[OAUTH_API] 특정 플랫폼 계정 조회 - "
|
||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
account = await social_account_service.get_account_by_platform(
|
||||
user_uuid=current_user.user_uuid,
|
||||
platform=platform,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if account is None:
|
||||
from app.social.exceptions import SocialAccountNotFoundError
|
||||
|
||||
raise SocialAccountNotFoundError(platform=platform.value)
|
||||
|
||||
return social_account_service._to_response(account)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/accounts/{account_id}",
|
||||
response_model=MessageResponse,
|
||||
summary="소셜 계정 연동 해제 (account_id)",
|
||||
description="""
|
||||
소셜 미디어 계정 연동을 해제합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **account_id**: 연동 해제할 소셜 계정 ID (SocialAccount.id)
|
||||
|
||||
## 연동 해제 시
|
||||
- 해당 플랫폼으로의 업로드가 불가능해집니다
|
||||
- 기존 업로드 기록은 유지됩니다
|
||||
- 재연동 시 동의 화면이 스킵됩니다
|
||||
""",
|
||||
)
|
||||
async def disconnect_by_account_id(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
소셜 계정 연동 해제 (account_id 기준)
|
||||
|
||||
account_id로 특정 소셜 계정의 연동을 해제합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[OAUTH_API] 소셜 연동 해제 (by account_id) - "
|
||||
f"user_uuid: {current_user.user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
|
||||
platform = await social_account_service.disconnect_by_account_id(
|
||||
user_uuid=current_user.user_uuid,
|
||||
account_id=account_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message=f"{platform} 계정 연동이 해제되었습니다.",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{platform}/disconnect",
|
||||
response_model=MessageResponse,
|
||||
summary="소셜 계정 연동 해제 (platform)",
|
||||
description="""
|
||||
소셜 미디어 계정 연동을 해제합니다.
|
||||
|
||||
**주의**: 이 API는 플랫폼당 1개의 계정만 연동된 경우에 사용합니다.
|
||||
여러 채널이 연동된 경우 `DELETE /accounts/{account_id}`를 사용하세요.
|
||||
|
||||
연동 해제 시:
|
||||
- 해당 플랫폼으로의 업로드가 불가능해집니다
|
||||
- 기존 업로드 기록은 유지됩니다
|
||||
""",
|
||||
deprecated=True,
|
||||
)
|
||||
async def disconnect(
|
||||
platform: SocialPlatform,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
소셜 계정 연동 해제 (platform 기준)
|
||||
|
||||
플랫폼으로 연동된 첫 번째 계정을 해제합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[OAUTH_API] 소셜 연동 해제 - "
|
||||
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
await social_account_service.disconnect(
|
||||
user_uuid=current_user.user_uuid,
|
||||
platform=platform,
|
||||
session=session,
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message=f"{platform.value} 계정 연동이 해제되었습니다.",
|
||||
)
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
|
||||
import logging, json
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from config import social_oauth_settings, db_settings
|
||||
from app.social.constants import YOUTUBE_SEO_HASH
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.social.schemas import (
|
||||
YoutubeDescriptionRequest,
|
||||
YoutubeDescriptionResponse,
|
||||
)
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import User
|
||||
from app.home.models import Project, MarketingIntel
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from fastapi import HTTPException, status
|
||||
from app.utils.prompts.prompts import yt_upload_prompt
|
||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||
|
||||
redis_seo_client = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=0,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/seo", tags=["Social SEO"])
|
||||
|
||||
@router.post(
|
||||
"/youtube",
|
||||
response_model=YoutubeDescriptionResponse,
|
||||
summary="유튜브 SEO descrption 생성",
|
||||
description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성",
|
||||
)
|
||||
async def youtube_seo_description(
|
||||
request_body: YoutubeDescriptionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> YoutubeDescriptionResponse:
|
||||
|
||||
# TODO : 나중에 Session Task_id 검증 미들웨어 만들면 추가해주세요.
|
||||
|
||||
logger.info(
|
||||
f"[youtube_seo_description] Try Cache - user: {current_user.user_uuid} / task_id : {request_body.task_id}"
|
||||
)
|
||||
cached = await get_yt_seo_in_redis(request_body.task_id)
|
||||
if cached: # redis hit
|
||||
return cached
|
||||
|
||||
logger.info(
|
||||
f"[youtube_seo_description] Cache miss - user: {current_user.user_uuid} "
|
||||
)
|
||||
updated_seo = await make_youtube_seo_description(request_body.task_id, current_user, session)
|
||||
await set_yt_seo_in_redis(request_body.task_id, updated_seo)
|
||||
|
||||
return updated_seo
|
||||
|
||||
async def make_youtube_seo_description(
|
||||
task_id: str,
|
||||
current_user: User,
|
||||
session: AsyncSession,
|
||||
) -> YoutubeDescriptionResponse:
|
||||
|
||||
logger.info(
|
||||
f"[make_youtube_seo_description] START - user: {current_user.user_uuid} "
|
||||
)
|
||||
try:
|
||||
project_query = await session.execute(
|
||||
select(Project)
|
||||
.where(
|
||||
Project.task_id == task_id,
|
||||
Project.user_uuid == current_user.user_uuid)
|
||||
.order_by(Project.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
project = project_query.scalar_one_or_none()
|
||||
marketing_query = await session.execute(
|
||||
select(MarketingIntel)
|
||||
.where(MarketingIntel.id == project.marketing_intelligence)
|
||||
)
|
||||
marketing_intelligence = marketing_query.scalar_one_or_none()
|
||||
|
||||
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
||||
|
||||
yt_seo_input_data = {
|
||||
"customer_name" : project.store_name,
|
||||
"detail_region_info" : project.detail_region_info,
|
||||
"marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False),
|
||||
"language" : project.language,
|
||||
"target_keywords" : hashtags
|
||||
}
|
||||
chatgpt = ChatgptService()
|
||||
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
||||
result_dict = {
|
||||
"title" : yt_seo_output.title,
|
||||
"description" : yt_seo_output.description,
|
||||
"keywords": hashtags
|
||||
}
|
||||
|
||||
result = YoutubeDescriptionResponse(**result_dict)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
async def get_yt_seo_in_redis(task_id:str) -> YoutubeDescriptionResponse | None:
|
||||
field = f"task_id:{task_id}"
|
||||
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
||||
if yt_seo_info:
|
||||
yt_seo = json.loads(yt_seo_info)
|
||||
else:
|
||||
return None
|
||||
return YoutubeDescriptionResponse(**yt_seo)
|
||||
|
||||
async def set_yt_seo_in_redis(task_id:str, yt_seo : YoutubeDescriptionResponse) -> None:
|
||||
field = f"task_id:{task_id}"
|
||||
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
|
||||
await redis_seo_client.hsetex(YOUTUBE_SEO_HASH, field, yt_seo_info, ex=3600)
|
||||
return
|
||||
|
||||
|
|
@ -1,424 +0,0 @@
|
|||
"""
|
||||
소셜 업로드 API 라우터
|
||||
|
||||
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
||||
"""
|
||||
|
||||
import logging, json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.social.constants import SocialPlatform, UploadStatus
|
||||
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
|
||||
from app.social.models import SocialUpload
|
||||
from app.social.schemas import (
|
||||
MessageResponse,
|
||||
SocialUploadHistoryItem,
|
||||
SocialUploadHistoryResponse,
|
||||
SocialUploadRequest,
|
||||
SocialUploadResponse,
|
||||
SocialUploadStatusResponse,
|
||||
)
|
||||
from app.social.services import social_account_service
|
||||
from app.social.worker.upload_task import process_social_upload
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import User
|
||||
from app.video.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SocialUploadResponse,
|
||||
summary="소셜 플랫폼에 영상 업로드 요청",
|
||||
description="""
|
||||
영상을 소셜 미디어 플랫폼에 업로드합니다.
|
||||
|
||||
## 사전 조건
|
||||
- 해당 플랫폼에 계정이 연동되어 있어야 합니다
|
||||
- 영상이 completed 상태여야 합니다 (result_movie_url 필요)
|
||||
|
||||
## 요청 필드
|
||||
- **video_id**: 업로드할 영상 ID
|
||||
- **social_account_id**: 업로드할 소셜 계정 ID (연동 계정 목록 조회 API에서 확인)
|
||||
- **title**: 영상 제목 (최대 100자)
|
||||
- **description**: 영상 설명 (최대 5000자)
|
||||
- **tags**: 태그 목록
|
||||
- **privacy_status**: 공개 상태 (public, unlisted, private)
|
||||
- **scheduled_at**: 예약 게시 시간 (선택사항)
|
||||
|
||||
## 업로드 상태
|
||||
업로드는 백그라운드에서 처리되며, 상태를 폴링하여 확인할 수 있습니다:
|
||||
- `pending`: 업로드 대기 중
|
||||
- `uploading`: 업로드 진행 중
|
||||
- `processing`: 플랫폼에서 처리 중
|
||||
- `completed`: 업로드 완료
|
||||
- `failed`: 업로드 실패
|
||||
""",
|
||||
)
|
||||
async def upload_to_social(
|
||||
body: SocialUploadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialUploadResponse:
|
||||
"""
|
||||
소셜 플랫폼에 영상 업로드 요청
|
||||
|
||||
백그라운드에서 영상을 다운로드하고 소셜 플랫폼에 업로드합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[UPLOAD_API] 업로드 요청 - "
|
||||
f"user_uuid: {current_user.user_uuid}, "
|
||||
f"video_id: {body.video_id}, "
|
||||
f"social_account_id: {body.social_account_id}"
|
||||
)
|
||||
|
||||
# 1. 영상 조회 및 검증
|
||||
video_result = await session.execute(
|
||||
select(Video).where(Video.id == body.video_id)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
|
||||
if not video:
|
||||
logger.warning(f"[UPLOAD_API] 영상 없음 - video_id: {body.video_id}")
|
||||
raise VideoNotFoundError(video_id=body.video_id)
|
||||
|
||||
if not video.result_movie_url:
|
||||
logger.warning(f"[UPLOAD_API] 영상 URL 없음 - video_id: {body.video_id}")
|
||||
raise VideoNotFoundError(
|
||||
video_id=body.video_id,
|
||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||
)
|
||||
|
||||
# 2. 소셜 계정 조회 (social_account_id로 직접 조회, 소유권 검증 포함)
|
||||
account = await social_account_service.get_account_by_id(
|
||||
user_uuid=current_user.user_uuid,
|
||||
account_id=body.social_account_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if not account:
|
||||
logger.warning(
|
||||
f"[UPLOAD_API] 연동 계정 없음 - "
|
||||
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
|
||||
)
|
||||
raise SocialAccountNotFoundError()
|
||||
|
||||
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
||||
in_progress_result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.video_id == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||
)
|
||||
)
|
||||
in_progress_upload = in_progress_result.scalar_one_or_none()
|
||||
|
||||
if in_progress_upload:
|
||||
logger.info(
|
||||
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
|
||||
)
|
||||
return SocialUploadResponse(
|
||||
success=True,
|
||||
upload_id=in_progress_upload.id,
|
||||
platform=account.platform,
|
||||
status=in_progress_upload.status,
|
||||
message="이미 업로드가 진행 중입니다.",
|
||||
)
|
||||
|
||||
# 4. 업로드 순번 계산 (동일 video + account 조합에서 최대 순번 + 1)
|
||||
max_seq_result = await session.execute(
|
||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
||||
SocialUpload.video_id == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
)
|
||||
)
|
||||
max_seq = max_seq_result.scalar() or 0
|
||||
next_seq = max_seq + 1
|
||||
|
||||
# 5. 새 업로드 레코드 생성 (항상 새로 생성하여 이력 보존)
|
||||
social_upload = SocialUpload(
|
||||
user_uuid=current_user.user_uuid,
|
||||
video_id=body.video_id,
|
||||
social_account_id=account.id,
|
||||
upload_seq=next_seq,
|
||||
platform=account.platform,
|
||||
status=UploadStatus.PENDING.value,
|
||||
upload_progress=0,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
tags=body.tags,
|
||||
privacy_status=body.privacy_status.value,
|
||||
platform_options={
|
||||
**(body.platform_options or {}),
|
||||
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
|
||||
},
|
||||
retry_count=0,
|
||||
)
|
||||
|
||||
session.add(social_upload)
|
||||
await session.commit()
|
||||
await session.refresh(social_upload)
|
||||
|
||||
logger.info(
|
||||
f"[UPLOAD_API] 업로드 레코드 생성 - "
|
||||
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
|
||||
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
|
||||
)
|
||||
|
||||
# 6. 백그라운드 태스크 등록
|
||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
||||
|
||||
return SocialUploadResponse(
|
||||
success=True,
|
||||
upload_id=social_upload.id,
|
||||
platform=account.platform,
|
||||
status=social_upload.status,
|
||||
message="업로드 요청이 접수되었습니다.",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{upload_id}/status",
|
||||
response_model=SocialUploadStatusResponse,
|
||||
summary="업로드 상태 조회",
|
||||
description="특정 업로드 작업의 상태를 조회합니다.",
|
||||
)
|
||||
async def get_upload_status(
|
||||
upload_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialUploadStatusResponse:
|
||||
"""
|
||||
업로드 상태 조회
|
||||
"""
|
||||
logger.info(f"[UPLOAD_API] 상태 조회 - upload_id: {upload_id}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.id == upload_id,
|
||||
SocialUpload.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if not upload:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="업로드 정보를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
return SocialUploadStatusResponse(
|
||||
upload_id=upload.id,
|
||||
video_id=upload.video_id,
|
||||
social_account_id=upload.social_account_id,
|
||||
upload_seq=upload.upload_seq,
|
||||
platform=upload.platform,
|
||||
status=UploadStatus(upload.status),
|
||||
upload_progress=upload.upload_progress,
|
||||
title=upload.title,
|
||||
platform_video_id=upload.platform_video_id,
|
||||
platform_url=upload.platform_url,
|
||||
error_message=upload.error_message,
|
||||
retry_count=upload.retry_count,
|
||||
created_at=upload.created_at,
|
||||
uploaded_at=upload.uploaded_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history",
|
||||
response_model=SocialUploadHistoryResponse,
|
||||
summary="업로드 이력 조회",
|
||||
description="사용자의 소셜 미디어 업로드 이력을 조회합니다.",
|
||||
)
|
||||
async def get_upload_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
|
||||
status: Optional[UploadStatus] = Query(None, description="상태 필터"),
|
||||
page: int = Query(1, ge=1, description="페이지 번호"),
|
||||
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
||||
) -> SocialUploadHistoryResponse:
|
||||
"""
|
||||
업로드 이력 조회
|
||||
"""
|
||||
logger.info(
|
||||
f"[UPLOAD_API] 이력 조회 - "
|
||||
f"user_uuid: {current_user.user_uuid}, page: {page}, size: {size}"
|
||||
)
|
||||
|
||||
# 기본 쿼리
|
||||
query = select(SocialUpload).where(
|
||||
SocialUpload.user_uuid == current_user.user_uuid
|
||||
)
|
||||
|
||||
count_query = select(func.count(SocialUpload.id)).where(
|
||||
SocialUpload.user_uuid == current_user.user_uuid
|
||||
)
|
||||
|
||||
# 필터 적용
|
||||
if platform:
|
||||
query = query.where(SocialUpload.platform == platform.value)
|
||||
count_query = count_query.where(SocialUpload.platform == platform.value)
|
||||
|
||||
if status:
|
||||
query = query.where(SocialUpload.status == status.value)
|
||||
count_query = count_query.where(SocialUpload.status == status.value)
|
||||
|
||||
# 총 개수 조회
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 페이지네이션 적용
|
||||
query = (
|
||||
query.order_by(SocialUpload.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
uploads = result.scalars().all()
|
||||
|
||||
items = [
|
||||
SocialUploadHistoryItem(
|
||||
upload_id=upload.id,
|
||||
video_id=upload.video_id,
|
||||
social_account_id=upload.social_account_id,
|
||||
upload_seq=upload.upload_seq,
|
||||
platform=upload.platform,
|
||||
status=upload.status,
|
||||
title=upload.title,
|
||||
platform_url=upload.platform_url,
|
||||
created_at=upload.created_at,
|
||||
uploaded_at=upload.uploaded_at,
|
||||
)
|
||||
for upload in uploads
|
||||
]
|
||||
|
||||
return SocialUploadHistoryResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{upload_id}/retry",
|
||||
response_model=SocialUploadResponse,
|
||||
summary="업로드 재시도",
|
||||
description="실패한 업로드를 재시도합니다.",
|
||||
)
|
||||
async def retry_upload(
|
||||
upload_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialUploadResponse:
|
||||
"""
|
||||
업로드 재시도
|
||||
|
||||
실패한 업로드를 다시 시도합니다.
|
||||
"""
|
||||
logger.info(f"[UPLOAD_API] 재시도 요청 - upload_id: {upload_id}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.id == upload_id,
|
||||
SocialUpload.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if not upload:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="업로드 정보를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
|
||||
)
|
||||
|
||||
# 상태 초기화
|
||||
upload.status = UploadStatus.PENDING.value
|
||||
upload.upload_progress = 0
|
||||
upload.error_message = None
|
||||
await session.commit()
|
||||
|
||||
# 백그라운드 태스크 등록
|
||||
background_tasks.add_task(process_social_upload, upload.id)
|
||||
|
||||
return SocialUploadResponse(
|
||||
success=True,
|
||||
upload_id=upload.id,
|
||||
platform=upload.platform,
|
||||
status=upload.status,
|
||||
message="업로드 재시도가 요청되었습니다.",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{upload_id}",
|
||||
response_model=MessageResponse,
|
||||
summary="업로드 취소",
|
||||
description="대기 중인 업로드를 취소합니다.",
|
||||
)
|
||||
async def cancel_upload(
|
||||
upload_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
업로드 취소
|
||||
|
||||
대기 중인 업로드를 취소합니다.
|
||||
이미 진행 중이거나 완료된 업로드는 취소할 수 없습니다.
|
||||
"""
|
||||
logger.info(f"[UPLOAD_API] 취소 요청 - upload_id: {upload_id}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.id == upload_id,
|
||||
SocialUpload.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if not upload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="업로드 정보를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
if upload.status != UploadStatus.PENDING.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
||||
)
|
||||
|
||||
upload.status = UploadStatus.CANCELLED.value
|
||||
await session.commit()
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message="업로드가 취소되었습니다.",
|
||||
)
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
"""
|
||||
Social Media Constants
|
||||
|
||||
소셜 미디어 플랫폼 관련 상수 및 Enum을 정의합니다.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SocialPlatform(str, Enum):
|
||||
"""지원하는 소셜 미디어 플랫폼"""
|
||||
|
||||
YOUTUBE = "youtube"
|
||||
INSTAGRAM = "instagram"
|
||||
FACEBOOK = "facebook"
|
||||
TIKTOK = "tiktok"
|
||||
|
||||
|
||||
class UploadStatus(str, Enum):
|
||||
"""업로드 상태"""
|
||||
|
||||
PENDING = "pending" # 업로드 대기 중
|
||||
UPLOADING = "uploading" # 업로드 진행 중
|
||||
PROCESSING = "processing" # 플랫폼에서 처리 중 (인코딩 등)
|
||||
COMPLETED = "completed" # 업로드 완료
|
||||
FAILED = "failed" # 업로드 실패
|
||||
CANCELLED = "cancelled" # 취소됨
|
||||
|
||||
|
||||
class PrivacyStatus(str, Enum):
|
||||
"""영상 공개 상태"""
|
||||
|
||||
PUBLIC = "public" # 전체 공개
|
||||
UNLISTED = "unlisted" # 일부 공개 (링크 있는 사람만)
|
||||
PRIVATE = "private" # 비공개
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 플랫폼별 설정
|
||||
# =============================================================================
|
||||
|
||||
PLATFORM_CONFIG = {
|
||||
SocialPlatform.YOUTUBE: {
|
||||
"name": "YouTube",
|
||||
"display_name": "유튜브",
|
||||
"max_file_size_mb": 256000, # 256GB
|
||||
"supported_formats": ["mp4", "mov", "avi", "wmv", "flv", "3gp", "webm"],
|
||||
"max_title_length": 100,
|
||||
"max_description_length": 5000,
|
||||
"max_tags": 500,
|
||||
"supported_privacy": ["public", "unlisted", "private"],
|
||||
"requires_channel": True,
|
||||
},
|
||||
SocialPlatform.INSTAGRAM: {
|
||||
"name": "Instagram",
|
||||
"display_name": "인스타그램",
|
||||
"max_file_size_mb": 4096, # 4GB (Reels)
|
||||
"supported_formats": ["mp4", "mov"],
|
||||
"max_duration_seconds": 90, # Reels 최대 90초
|
||||
"min_duration_seconds": 3,
|
||||
"aspect_ratios": ["9:16", "1:1", "4:5"],
|
||||
"max_caption_length": 2200,
|
||||
"requires_business_account": True,
|
||||
},
|
||||
SocialPlatform.FACEBOOK: {
|
||||
"name": "Facebook",
|
||||
"display_name": "페이스북",
|
||||
"max_file_size_mb": 10240, # 10GB
|
||||
"supported_formats": ["mp4", "mov"],
|
||||
"max_duration_seconds": 14400, # 4시간
|
||||
"max_title_length": 255,
|
||||
"max_description_length": 5000,
|
||||
"requires_page": True,
|
||||
},
|
||||
SocialPlatform.TIKTOK: {
|
||||
"name": "TikTok",
|
||||
"display_name": "틱톡",
|
||||
"max_file_size_mb": 4096, # 4GB
|
||||
"supported_formats": ["mp4", "mov", "webm"],
|
||||
"max_duration_seconds": 600, # 10분
|
||||
"min_duration_seconds": 1,
|
||||
"max_title_length": 150,
|
||||
"requires_business_account": True,
|
||||
},
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# YouTube OAuth Scopes
|
||||
# =============================================================================
|
||||
|
||||
YOUTUBE_SCOPES = [
|
||||
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
|
||||
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
|
||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||
]
|
||||
|
||||
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
|
||||
|
||||
# =============================================================================
|
||||
# Instagram/Facebook OAuth Scopes (추후 구현)
|
||||
# =============================================================================
|
||||
|
||||
# INSTAGRAM_SCOPES = [
|
||||
# "instagram_basic",
|
||||
# "instagram_content_publish",
|
||||
# "pages_read_engagement",
|
||||
# "business_management",
|
||||
# ]
|
||||
|
||||
# FACEBOOK_SCOPES = [
|
||||
# "pages_manage_posts",
|
||||
# "pages_read_engagement",
|
||||
# "publish_video",
|
||||
# "pages_show_list",
|
||||
# ]
|
||||
|
||||
# =============================================================================
|
||||
# TikTok OAuth Scopes (추후 구현)
|
||||
# =============================================================================
|
||||
|
||||
# TIKTOK_SCOPES = [
|
||||
# "user.info.basic",
|
||||
# "video.upload",
|
||||
# "video.publish",
|
||||
# ]
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
"""
|
||||
Social Media Exceptions
|
||||
|
||||
소셜 미디어 연동 관련 예외 클래스를 정의합니다.
|
||||
"""
|
||||
|
||||
from fastapi import status
|
||||
|
||||
|
||||
class SocialException(Exception):
|
||||
"""소셜 미디어 기본 예외"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code: str = "SOCIAL_ERROR",
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OAuth 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OAuthException(SocialException):
|
||||
"""OAuth 관련 예외 기본 클래스"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "OAuth 인증 중 오류가 발생했습니다.",
|
||||
status_code: int = status.HTTP_401_UNAUTHORIZED,
|
||||
code: str = "OAUTH_ERROR",
|
||||
):
|
||||
super().__init__(message, status_code, code)
|
||||
|
||||
|
||||
class InvalidStateError(OAuthException):
|
||||
"""CSRF state 토큰 불일치"""
|
||||
|
||||
def __init__(self, message: str = "유효하지 않은 인증 세션입니다. 다시 시도해주세요."):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="INVALID_STATE",
|
||||
)
|
||||
|
||||
|
||||
class OAuthStateExpiredError(OAuthException):
|
||||
"""OAuth state 토큰 만료"""
|
||||
|
||||
def __init__(self, message: str = "인증 세션이 만료되었습니다. 다시 시도해주세요."):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="STATE_EXPIRED",
|
||||
)
|
||||
|
||||
|
||||
class OAuthTokenError(OAuthException):
|
||||
"""OAuth 토큰 교환 실패"""
|
||||
|
||||
def __init__(self, platform: str, message: str = ""):
|
||||
error_message = f"{platform} 토큰 발급에 실패했습니다."
|
||||
if message:
|
||||
error_message += f" ({message})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_EXCHANGE_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class TokenRefreshError(OAuthException):
|
||||
"""토큰 갱신 실패"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"{platform} 토큰 갱신에 실패했습니다. 재연동이 필요합니다.",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_REFRESH_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class OAuthCodeExchangeError(OAuthException):
|
||||
"""OAuth 인가 코드 교환 실패"""
|
||||
|
||||
def __init__(self, platform: str, detail: str = ""):
|
||||
error_message = f"{platform} 인가 코드 교환에 실패했습니다."
|
||||
if detail:
|
||||
error_message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="CODE_EXCHANGE_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class OAuthTokenRefreshError(OAuthException):
|
||||
"""OAuth 토큰 갱신 실패"""
|
||||
|
||||
def __init__(self, platform: str, detail: str = ""):
|
||||
error_message = f"{platform} 토큰 갱신에 실패했습니다."
|
||||
if detail:
|
||||
error_message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_REFRESH_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class TokenExpiredError(OAuthException):
|
||||
"""토큰 만료"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"{platform} 인증이 만료되었습니다. 재연동이 필요합니다.",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_EXPIRED",
|
||||
)
|
||||
self.platform = platform
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 소셜 계정 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SocialAccountException(SocialException):
|
||||
"""소셜 계정 관련 예외 기본 클래스"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SocialAccountNotFoundError(SocialAccountException):
|
||||
"""연동된 계정을 찾을 수 없음"""
|
||||
|
||||
def __init__(self, platform: str = ""):
|
||||
message = f"{platform} 계정이 연동되어 있지 않습니다." if platform else "연동된 소셜 계정이 없습니다."
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="SOCIAL_ACCOUNT_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountAlreadyExistsError(SocialAccountException):
|
||||
"""이미 연동된 계정이 존재함"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"이미 {platform} 계정이 연동되어 있습니다.",
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
code="SOCIAL_ACCOUNT_EXISTS",
|
||||
)
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
SocialAccountAlreadyConnectedError = SocialAccountAlreadyExistsError
|
||||
|
||||
|
||||
class SocialAccountInactiveError(SocialAccountException):
|
||||
"""비활성화된 소셜 계정"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"{platform} 계정이 비활성화 상태입니다. 재연동이 필요합니다.",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
code="SOCIAL_ACCOUNT_INACTIVE",
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountError(SocialAccountException):
|
||||
"""소셜 계정 일반 오류"""
|
||||
|
||||
def __init__(self, platform: str, detail: str = ""):
|
||||
error_message = f"{platform} 계정 처리 중 오류가 발생했습니다."
|
||||
if detail:
|
||||
error_message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="SOCIAL_ACCOUNT_ERROR",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 업로드 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class UploadException(SocialException):
|
||||
"""업로드 관련 예외 기본 클래스"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UploadError(UploadException):
|
||||
"""업로드 일반 오류"""
|
||||
|
||||
def __init__(self, platform: str, detail: str = ""):
|
||||
error_message = f"{platform} 업로드 중 오류가 발생했습니다."
|
||||
if detail:
|
||||
error_message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="UPLOAD_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class UploadValidationError(UploadException):
|
||||
"""업로드 유효성 검사 실패"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="UPLOAD_VALIDATION_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class VideoNotFoundError(UploadException):
|
||||
"""영상을 찾을 수 없음"""
|
||||
|
||||
def __init__(self, video_id: int, detail: str = ""):
|
||||
message = f"영상을 찾을 수 없습니다. (video_id: {video_id})"
|
||||
if detail:
|
||||
message = detail
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="VIDEO_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class VideoNotReadyError(UploadException):
|
||||
"""영상이 준비되지 않음"""
|
||||
|
||||
def __init__(self, video_id: int):
|
||||
super().__init__(
|
||||
message=f"영상이 아직 준비되지 않았습니다. (video_id: {video_id})",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="VIDEO_NOT_READY",
|
||||
)
|
||||
|
||||
|
||||
class UploadFailedError(UploadException):
|
||||
"""업로드 실패"""
|
||||
|
||||
def __init__(self, platform: str, message: str = ""):
|
||||
error_message = f"{platform} 업로드에 실패했습니다."
|
||||
if message:
|
||||
error_message += f" ({message})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="UPLOAD_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class UploadQuotaExceededError(UploadException):
|
||||
"""업로드 할당량 초과"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"{platform} 일일 업로드 할당량이 초과되었습니다.",
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
code="UPLOAD_QUOTA_EXCEEDED",
|
||||
)
|
||||
|
||||
|
||||
class UploadNotFoundError(UploadException):
|
||||
"""업로드 기록을 찾을 수 없음"""
|
||||
|
||||
def __init__(self, upload_id: int):
|
||||
super().__init__(
|
||||
message=f"업로드 기록을 찾을 수 없습니다. (upload_id: {upload_id})",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="UPLOAD_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 플랫폼 API 관련 예외
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PlatformAPIError(SocialException):
|
||||
"""플랫폼 API 호출 오류"""
|
||||
|
||||
def __init__(self, platform: str, message: str = ""):
|
||||
error_message = f"{platform} API 호출 중 오류가 발생했습니다."
|
||||
if message:
|
||||
error_message += f" ({message})"
|
||||
super().__init__(
|
||||
message=error_message,
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
code="PLATFORM_API_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class RateLimitError(PlatformAPIError):
|
||||
"""API 요청 한도 초과"""
|
||||
|
||||
def __init__(self, platform: str, retry_after: int | None = None):
|
||||
message = f"{platform} API 요청 한도가 초과되었습니다."
|
||||
if retry_after:
|
||||
message += f" {retry_after}초 후에 다시 시도해주세요."
|
||||
super().__init__(
|
||||
platform=platform,
|
||||
message=message,
|
||||
)
|
||||
self.retry_after = retry_after
|
||||
self.code = "RATE_LIMIT_EXCEEDED"
|
||||
|
||||
|
||||
class UnsupportedPlatformError(SocialException):
|
||||
"""지원하지 않는 플랫폼"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
super().__init__(
|
||||
message=f"지원하지 않는 플랫폼입니다: {platform}",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="UNSUPPORTED_PLATFORM",
|
||||
)
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
"""
|
||||
Social Media Models
|
||||
|
||||
소셜 미디어 업로드 관련 SQLAlchemy 모델을 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.mysql import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.user.models import SocialAccount
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class SocialUpload(Base):
|
||||
"""
|
||||
소셜 미디어 업로드 기록 테이블
|
||||
|
||||
영상의 소셜 미디어 플랫폼별 업로드 상태를 추적합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||
video_id: Video 외래키
|
||||
social_account_id: SocialAccount 외래키
|
||||
upload_seq: 업로드 순번 (동일 영상+채널 조합 내 순번, 관리자 추적용)
|
||||
platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
|
||||
status: 업로드 상태 (pending, uploading, processing, completed, failed)
|
||||
upload_progress: 업로드 진행률 (0-100)
|
||||
platform_video_id: 플랫폼에서 부여한 영상 ID
|
||||
platform_url: 플랫폼에서의 영상 URL
|
||||
title: 영상 제목
|
||||
description: 영상 설명
|
||||
tags: 태그 목록 (JSON)
|
||||
privacy_status: 공개 상태 (public, unlisted, private)
|
||||
platform_options: 플랫폼별 추가 옵션 (JSON)
|
||||
error_message: 에러 메시지 (실패 시)
|
||||
retry_count: 재시도 횟수
|
||||
uploaded_at: 업로드 완료 시간
|
||||
created_at: 생성 일시
|
||||
updated_at: 수정 일시
|
||||
|
||||
Relationships:
|
||||
video: 연결된 Video
|
||||
social_account: 연결된 SocialAccount
|
||||
"""
|
||||
|
||||
__tablename__ = "social_upload"
|
||||
__table_args__ = (
|
||||
Index("idx_social_upload_user_uuid", "user_uuid"),
|
||||
Index("idx_social_upload_video_id", "video_id"),
|
||||
Index("idx_social_upload_social_account_id", "social_account_id"),
|
||||
Index("idx_social_upload_platform", "platform"),
|
||||
Index("idx_social_upload_status", "status"),
|
||||
Index("idx_social_upload_created_at", "created_at"),
|
||||
# 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능)
|
||||
Index("idx_social_upload_video_account", "video_id", "social_account_id"),
|
||||
# 순번 조회용 인덱스
|
||||
Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
},
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 기본 식별자
|
||||
# ==========================================================================
|
||||
id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
autoincrement=True,
|
||||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 관계 필드
|
||||
# ==========================================================================
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 UUID (User.user_uuid 참조)",
|
||||
)
|
||||
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Video 외래키",
|
||||
)
|
||||
|
||||
social_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("social_account.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="SocialAccount 외래키",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 업로드 순번 (관리자 추적용)
|
||||
# ==========================================================================
|
||||
upload_seq: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=1,
|
||||
comment="업로드 순번 (동일 영상+채널 조합 내 순번, 1부터 시작)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 플랫폼 정보
|
||||
# ==========================================================================
|
||||
platform: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 업로드 상태
|
||||
# ==========================================================================
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
comment="업로드 상태 (pending, uploading, processing, completed, failed)",
|
||||
)
|
||||
|
||||
upload_progress: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="업로드 진행률 (0-100)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 플랫폼 결과
|
||||
# ==========================================================================
|
||||
platform_video_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="플랫폼에서 부여한 영상 ID",
|
||||
)
|
||||
|
||||
platform_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="플랫폼에서의 영상 URL",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 메타데이터
|
||||
# ==========================================================================
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
comment="영상 제목",
|
||||
)
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="영상 설명",
|
||||
)
|
||||
|
||||
tags: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="태그 목록 (JSON 배열)",
|
||||
)
|
||||
|
||||
privacy_status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="private",
|
||||
comment="공개 상태 (public, unlisted, private)",
|
||||
)
|
||||
|
||||
platform_options: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="플랫폼별 추가 옵션 (JSON)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 에러 정보
|
||||
# ==========================================================================
|
||||
error_message: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="에러 메시지 (실패 시)",
|
||||
)
|
||||
|
||||
retry_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="재시도 횟수",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 시간 정보
|
||||
# ==========================================================================
|
||||
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="업로드 완료 시간",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
comment="수정 일시",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Relationships
|
||||
# ==========================================================================
|
||||
video: Mapped["Video"] = relationship(
|
||||
"Video",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
social_account: Mapped["SocialAccount"] = relationship(
|
||||
"SocialAccount",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<SocialUpload("
|
||||
f"id={self.id}, "
|
||||
f"video_id={self.video_id}, "
|
||||
f"account_id={self.social_account_id}, "
|
||||
f"seq={self.upload_seq}, "
|
||||
f"platform='{self.platform}', "
|
||||
f"status='{self.status}'"
|
||||
f")>"
|
||||
)
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
"""
|
||||
Social OAuth Module
|
||||
|
||||
소셜 미디어 OAuth 클라이언트 모듈입니다.
|
||||
"""
|
||||
|
||||
from app.social.constants import SocialPlatform
|
||||
from app.social.oauth.base import BaseOAuthClient
|
||||
|
||||
|
||||
def get_oauth_client(platform: SocialPlatform) -> BaseOAuthClient:
|
||||
"""
|
||||
플랫폼에 맞는 OAuth 클라이언트 반환
|
||||
|
||||
Args:
|
||||
platform: 소셜 플랫폼
|
||||
|
||||
Returns:
|
||||
BaseOAuthClient: OAuth 클라이언트 인스턴스
|
||||
|
||||
Raises:
|
||||
ValueError: 지원하지 않는 플랫폼인 경우
|
||||
"""
|
||||
if platform == SocialPlatform.YOUTUBE:
|
||||
from app.social.oauth.youtube import YouTubeOAuthClient
|
||||
|
||||
return YouTubeOAuthClient()
|
||||
|
||||
# 추후 확장
|
||||
# elif platform == SocialPlatform.INSTAGRAM:
|
||||
# from app.social.oauth.instagram import InstagramOAuthClient
|
||||
# return InstagramOAuthClient()
|
||||
# elif platform == SocialPlatform.FACEBOOK:
|
||||
# from app.social.oauth.facebook import FacebookOAuthClient
|
||||
# return FacebookOAuthClient()
|
||||
# elif platform == SocialPlatform.TIKTOK:
|
||||
# from app.social.oauth.tiktok import TikTokOAuthClient
|
||||
# return TikTokOAuthClient()
|
||||
|
||||
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseOAuthClient",
|
||||
"get_oauth_client",
|
||||
]
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"""
|
||||
Base OAuth Client
|
||||
|
||||
소셜 미디어 OAuth 클라이언트의 추상 기본 클래스입니다.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from app.social.constants import SocialPlatform
|
||||
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
|
||||
|
||||
|
||||
class BaseOAuthClient(ABC):
|
||||
"""
|
||||
소셜 미디어 OAuth 클라이언트 추상 기본 클래스
|
||||
|
||||
모든 플랫폼별 OAuth 클라이언트는 이 클래스를 상속받아 구현합니다.
|
||||
|
||||
Attributes:
|
||||
platform: 소셜 플랫폼 종류
|
||||
"""
|
||||
|
||||
platform: SocialPlatform
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""
|
||||
OAuth 인증 URL 생성
|
||||
|
||||
Args:
|
||||
state: CSRF 방지용 state 토큰
|
||||
|
||||
Returns:
|
||||
str: OAuth 인증 페이지 URL
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""
|
||||
인가 코드로 액세스 토큰 교환
|
||||
|
||||
Args:
|
||||
code: OAuth 인가 코드
|
||||
|
||||
Returns:
|
||||
OAuthTokenResponse: 액세스 토큰 및 리프레시 토큰
|
||||
|
||||
Raises:
|
||||
OAuthCodeExchangeError: 토큰 교환 실패 시
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
|
||||
"""
|
||||
리프레시 토큰으로 액세스 토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
|
||||
Returns:
|
||||
OAuthTokenResponse: 새 액세스 토큰
|
||||
|
||||
Raises:
|
||||
OAuthTokenRefreshError: 토큰 갱신 실패 시
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
|
||||
"""
|
||||
플랫폼 사용자 정보 조회
|
||||
|
||||
Args:
|
||||
access_token: 액세스 토큰
|
||||
|
||||
Returns:
|
||||
PlatformUserInfo: 플랫폼 사용자 정보
|
||||
|
||||
Raises:
|
||||
SocialAccountError: 사용자 정보 조회 실패 시
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def revoke_token(self, token: str) -> bool:
|
||||
"""
|
||||
토큰 폐기 (연동 해제 시)
|
||||
|
||||
Args:
|
||||
token: 폐기할 토큰
|
||||
|
||||
Returns:
|
||||
bool: 폐기 성공 여부
|
||||
"""
|
||||
pass
|
||||
|
||||
def is_token_expired(self, expires_in: Optional[int]) -> bool:
|
||||
"""
|
||||
토큰 만료 여부 확인 (만료 10분 전이면 True)
|
||||
|
||||
Args:
|
||||
expires_in: 토큰 만료까지 남은 시간(초)
|
||||
|
||||
Returns:
|
||||
bool: 갱신 필요 여부
|
||||
"""
|
||||
if expires_in is None:
|
||||
return False
|
||||
# 만료 10분(600초) 전이면 갱신 필요
|
||||
return expires_in <= 600
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
"""
|
||||
YouTube OAuth Client
|
||||
|
||||
Google OAuth를 사용한 YouTube 인증 클라이언트입니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from config import social_oauth_settings
|
||||
from app.social.constants import SocialPlatform, YOUTUBE_SCOPES
|
||||
from app.social.exceptions import (
|
||||
OAuthCodeExchangeError,
|
||||
OAuthTokenRefreshError,
|
||||
SocialAccountError,
|
||||
)
|
||||
from app.social.oauth.base import BaseOAuthClient
|
||||
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YouTubeOAuthClient(BaseOAuthClient):
|
||||
"""
|
||||
YouTube OAuth 클라이언트
|
||||
|
||||
Google OAuth 2.0을 사용하여 YouTube 계정 인증을 처리합니다.
|
||||
"""
|
||||
|
||||
platform = SocialPlatform.YOUTUBE
|
||||
|
||||
# Google OAuth 엔드포인트
|
||||
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
YOUTUBE_CHANNEL_URL = "https://www.googleapis.com/youtube/v3/channels"
|
||||
REVOKE_URL = "https://oauth2.googleapis.com/revoke"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.client_id = social_oauth_settings.YOUTUBE_CLIENT_ID
|
||||
self.client_secret = social_oauth_settings.YOUTUBE_CLIENT_SECRET
|
||||
self.redirect_uri = social_oauth_settings.YOUTUBE_REDIRECT_URI
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""
|
||||
Google OAuth 인증 URL 생성
|
||||
|
||||
Args:
|
||||
state: CSRF 방지용 state 토큰
|
||||
|
||||
Returns:
|
||||
str: Google OAuth 인증 페이지 URL
|
||||
"""
|
||||
params = {
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(YOUTUBE_SCOPES),
|
||||
"access_type": "offline", # refresh_token 받기 위해 필요
|
||||
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
|
||||
"state": state,
|
||||
}
|
||||
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
||||
logger.debug(f"[YOUTUBE_OAUTH] 인증 URL 생성: {url[:100]}...")
|
||||
return url
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""
|
||||
인가 코드로 액세스 토큰 교환
|
||||
|
||||
Args:
|
||||
code: OAuth 인가 코드
|
||||
|
||||
Returns:
|
||||
OAuthTokenResponse: 액세스 토큰 및 리프레시 토큰
|
||||
|
||||
Raises:
|
||||
OAuthCodeExchangeError: 토큰 교환 실패 시
|
||||
"""
|
||||
logger.info(f"[YOUTUBE_OAUTH] 토큰 교환 시작 - code: {code[:20]}...")
|
||||
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": self.redirect_uri,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 교환 성공")
|
||||
logger.debug(
|
||||
f"[YOUTUBE_OAUTH] 토큰 정보 - "
|
||||
f"expires_in: {token_data.get('expires_in')}, "
|
||||
f"scope: {token_data.get('scope')}"
|
||||
)
|
||||
|
||||
return OAuthTokenResponse(
|
||||
access_token=token_data["access_token"],
|
||||
refresh_token=token_data.get("refresh_token"),
|
||||
expires_in=token_data["expires_in"],
|
||||
token_type=token_data.get("token_type", "Bearer"),
|
||||
scope=token_data.get("scope"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = e.response.text if e.response else str(e)
|
||||
logger.error(
|
||||
f"[YOUTUBE_OAUTH] 토큰 교환 실패 - "
|
||||
f"status: {e.response.status_code}, error: {error_detail}"
|
||||
)
|
||||
raise OAuthCodeExchangeError(
|
||||
platform=self.platform.value,
|
||||
detail=f"토큰 교환 실패: {error_detail}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 교환 중 예외 발생: {e}")
|
||||
raise OAuthCodeExchangeError(
|
||||
platform=self.platform.value,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
|
||||
"""
|
||||
리프레시 토큰으로 액세스 토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
|
||||
Returns:
|
||||
OAuthTokenResponse: 새 액세스 토큰
|
||||
|
||||
Raises:
|
||||
OAuthTokenRefreshError: 토큰 갱신 실패 시
|
||||
"""
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 갱신 시작")
|
||||
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 갱신 성공")
|
||||
|
||||
return OAuthTokenResponse(
|
||||
access_token=token_data["access_token"],
|
||||
refresh_token=refresh_token, # Google은 refresh_token 재발급 안함
|
||||
expires_in=token_data["expires_in"],
|
||||
token_type=token_data.get("token_type", "Bearer"),
|
||||
scope=token_data.get("scope"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = e.response.text if e.response else str(e)
|
||||
logger.error(
|
||||
f"[YOUTUBE_OAUTH] 토큰 갱신 실패 - "
|
||||
f"status: {e.response.status_code}, error: {error_detail}"
|
||||
)
|
||||
raise OAuthTokenRefreshError(
|
||||
platform=self.platform.value,
|
||||
detail=f"토큰 갱신 실패: {error_detail}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 갱신 중 예외 발생: {e}")
|
||||
raise OAuthTokenRefreshError(
|
||||
platform=self.platform.value,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
|
||||
"""
|
||||
YouTube 채널 정보 조회
|
||||
|
||||
Args:
|
||||
access_token: 액세스 토큰
|
||||
|
||||
Returns:
|
||||
PlatformUserInfo: YouTube 채널 정보
|
||||
|
||||
Raises:
|
||||
SocialAccountError: 정보 조회 실패 시
|
||||
"""
|
||||
logger.info("[YOUTUBE_OAUTH] 사용자/채널 정보 조회 시작")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
# 1. Google 사용자 기본 정보 조회
|
||||
userinfo_response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers=headers,
|
||||
)
|
||||
userinfo_response.raise_for_status()
|
||||
userinfo = userinfo_response.json()
|
||||
|
||||
# 2. YouTube 채널 정보 조회
|
||||
channel_params = {
|
||||
"part": "snippet,statistics",
|
||||
"mine": "true",
|
||||
}
|
||||
channel_response = await client.get(
|
||||
self.YOUTUBE_CHANNEL_URL,
|
||||
headers=headers,
|
||||
params=channel_params,
|
||||
)
|
||||
channel_response.raise_for_status()
|
||||
channel_data = channel_response.json()
|
||||
|
||||
# 채널이 없는 경우
|
||||
if not channel_data.get("items"):
|
||||
logger.warning("[YOUTUBE_OAUTH] YouTube 채널 없음")
|
||||
raise SocialAccountError(
|
||||
platform=self.platform.value,
|
||||
detail="YouTube 채널이 없습니다. 채널을 먼저 생성해주세요.",
|
||||
)
|
||||
|
||||
channel = channel_data["items"][0]
|
||||
snippet = channel.get("snippet", {})
|
||||
statistics = channel.get("statistics", {})
|
||||
|
||||
logger.info(
|
||||
f"[YOUTUBE_OAUTH] 채널 정보 조회 성공 - "
|
||||
f"channel_id: {channel['id']}, "
|
||||
f"title: {snippet.get('title')}"
|
||||
)
|
||||
|
||||
return PlatformUserInfo(
|
||||
platform_user_id=channel["id"],
|
||||
username=snippet.get("customUrl"), # @username 형태
|
||||
display_name=snippet.get("title"),
|
||||
profile_image_url=snippet.get("thumbnails", {})
|
||||
.get("default", {})
|
||||
.get("url"),
|
||||
platform_data={
|
||||
"channel_id": channel["id"],
|
||||
"channel_title": snippet.get("title"),
|
||||
"channel_description": snippet.get("description"),
|
||||
"custom_url": snippet.get("customUrl"),
|
||||
"subscriber_count": statistics.get("subscriberCount"),
|
||||
"video_count": statistics.get("videoCount"),
|
||||
"view_count": statistics.get("viewCount"),
|
||||
"google_user_id": userinfo.get("id"),
|
||||
"google_email": userinfo.get("email"),
|
||||
},
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = e.response.text if e.response else str(e)
|
||||
logger.error(
|
||||
f"[YOUTUBE_OAUTH] 정보 조회 실패 - "
|
||||
f"status: {e.response.status_code}, error: {error_detail}"
|
||||
)
|
||||
raise SocialAccountError(
|
||||
platform=self.platform.value,
|
||||
detail=f"사용자 정보 조회 실패: {error_detail}",
|
||||
)
|
||||
except SocialAccountError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_OAUTH] 정보 조회 중 예외 발생: {e}")
|
||||
raise SocialAccountError(
|
||||
platform=self.platform.value,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def revoke_token(self, token: str) -> bool:
|
||||
"""
|
||||
토큰 폐기 (연동 해제 시)
|
||||
|
||||
Args:
|
||||
token: 폐기할 토큰 (access_token 또는 refresh_token)
|
||||
|
||||
Returns:
|
||||
bool: 폐기 성공 여부
|
||||
"""
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 폐기 시작")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
self.REVOKE_URL,
|
||||
data={"token": token},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("[YOUTUBE_OAUTH] 토큰 폐기 성공")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"[YOUTUBE_OAUTH] 토큰 폐기 실패 - "
|
||||
f"status: {response.status_code}, body: {response.text}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_OAUTH] 토큰 폐기 중 예외 발생: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
youtube_oauth_client = YouTubeOAuthClient()
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
"""
|
||||
Social Media Schemas
|
||||
|
||||
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.social.constants import PrivacyStatus, SocialPlatform, UploadStatus
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OAuth 관련 스키마
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SocialConnectResponse(BaseModel):
|
||||
"""소셜 계정 연동 시작 응답"""
|
||||
|
||||
auth_url: str = Field(..., description="OAuth 인증 URL")
|
||||
state: str = Field(..., description="CSRF 방지용 state 토큰")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
||||
"state": "abc123xyz",
|
||||
"platform": "youtube",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountResponse(BaseModel):
|
||||
"""연동된 소셜 계정 정보"""
|
||||
|
||||
id: int = Field(..., description="소셜 계정 ID")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID")
|
||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명")
|
||||
display_name: Optional[str] = Field(None, description="표시 이름")
|
||||
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
|
||||
is_active: bool = Field(..., description="활성화 상태")
|
||||
connected_at: datetime = Field(..., description="연동 일시")
|
||||
platform_data: Optional[dict[str, Any]] = Field(
|
||||
None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"platform": "youtube",
|
||||
"platform_user_id": "UC1234567890",
|
||||
"platform_username": "my_channel",
|
||||
"display_name": "My Channel",
|
||||
"profile_image_url": "https://...",
|
||||
"is_active": True,
|
||||
"connected_at": "2024-01-15T12:00:00",
|
||||
"platform_data": {
|
||||
"channel_id": "UC1234567890",
|
||||
"channel_title": "My Channel",
|
||||
"subscriber_count": 1000,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountListResponse(BaseModel):
|
||||
"""연동된 소셜 계정 목록 응답"""
|
||||
|
||||
accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록")
|
||||
total: int = Field(..., description="총 연동 계정 수")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"accounts": [
|
||||
{
|
||||
"id": 1,
|
||||
"platform": "youtube",
|
||||
"platform_user_id": "UC1234567890",
|
||||
"platform_username": "my_channel",
|
||||
"display_name": "My Channel",
|
||||
"is_active": True,
|
||||
"connected_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 내부 사용 스키마 (OAuth 토큰 응답)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OAuthTokenResponse(BaseModel):
|
||||
"""OAuth 토큰 응답 (내부 사용)"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
expires_in: int
|
||||
token_type: str = "Bearer"
|
||||
scope: Optional[str] = None
|
||||
|
||||
|
||||
class PlatformUserInfo(BaseModel):
|
||||
"""플랫폼 사용자 정보 (내부 사용)"""
|
||||
|
||||
platform_user_id: str
|
||||
username: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
profile_image_url: Optional[str] = None
|
||||
platform_data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 업로드 관련 스키마
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SocialUploadRequest(BaseModel):
|
||||
"""소셜 업로드 요청"""
|
||||
|
||||
video_id: int = Field(..., description="업로드할 영상 ID")
|
||||
social_account_id: int = Field(..., description="업로드할 소셜 계정 ID (연동 계정 목록의 id)")
|
||||
title: str = Field(..., min_length=1, max_length=100, description="영상 제목")
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=5000, description="영상 설명"
|
||||
)
|
||||
tags: Optional[list[str]] = Field(None, description="태그 목록 (쉼표로 구분된 문자열도 가능)")
|
||||
privacy_status: PrivacyStatus = Field(
|
||||
default=PrivacyStatus.PRIVATE, description="공개 상태 (public, unlisted, private)"
|
||||
)
|
||||
scheduled_at: Optional[datetime] = Field(
|
||||
None, description="예약 게시 시간 (없으면 즉시 게시)"
|
||||
)
|
||||
platform_options: Optional[dict[str, Any]] = Field(
|
||||
None, description="플랫폼별 추가 옵션"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"video_id": 123,
|
||||
"social_account_id": 1,
|
||||
"title": "도그앤조이 애견펜션 2026.02.02",
|
||||
"description": "영상 설명입니다.",
|
||||
"tags": ["여행", "vlog", "애견펜션"],
|
||||
"privacy_status": "public",
|
||||
"scheduled_at": "2026-02-02T15:00:00",
|
||||
"platform_options": {
|
||||
"category_id": "22", # YouTube 카테고리
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialUploadResponse(BaseModel):
|
||||
"""소셜 업로드 요청 응답"""
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
status: str = Field(..., description="업로드 상태")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"upload_id": 456,
|
||||
"platform": "youtube",
|
||||
"status": "pending",
|
||||
"message": "업로드 요청이 접수되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialUploadStatusResponse(BaseModel):
|
||||
"""업로드 상태 조회 응답"""
|
||||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
video_id: int = Field(..., description="영상 ID")
|
||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
status: UploadStatus = Field(..., description="업로드 상태")
|
||||
upload_progress: int = Field(..., description="업로드 진행률 (0-100)")
|
||||
title: str = Field(..., description="영상 제목")
|
||||
platform_video_id: Optional[str] = Field(None, description="플랫폼 영상 ID")
|
||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지")
|
||||
retry_count: int = Field(default=0, description="재시도 횟수")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"upload_id": 456,
|
||||
"video_id": 123,
|
||||
"social_account_id": 1,
|
||||
"upload_seq": 2,
|
||||
"platform": "youtube",
|
||||
"status": "completed",
|
||||
"upload_progress": 100,
|
||||
"title": "나의 첫 영상",
|
||||
"platform_video_id": "dQw4w9WgXcQ",
|
||||
"platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"error_message": None,
|
||||
"retry_count": 0,
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
"uploaded_at": "2024-01-15T12:05:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialUploadHistoryItem(BaseModel):
|
||||
"""업로드 이력 아이템"""
|
||||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
video_id: int = Field(..., description="영상 ID")
|
||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
status: str = Field(..., description="업로드 상태")
|
||||
title: str = Field(..., description="영상 제목")
|
||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SocialUploadHistoryResponse(BaseModel):
|
||||
"""업로드 이력 목록 응답"""
|
||||
|
||||
items: list[SocialUploadHistoryItem] = Field(..., description="업로드 이력 목록")
|
||||
total: int = Field(..., description="전체 개수")
|
||||
page: int = Field(..., description="현재 페이지")
|
||||
size: int = Field(..., description="페이지 크기")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"items": [
|
||||
{
|
||||
"upload_id": 456,
|
||||
"video_id": 123,
|
||||
"platform": "youtube",
|
||||
"status": "completed",
|
||||
"title": "나의 첫 영상",
|
||||
"platform_url": "https://www.youtube.com/watch?v=xxx",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
"uploaded_at": "2024-01-15T12:05:00",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"size": 20,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
class YoutubeDescriptionRequest(BaseModel):
|
||||
"""유튜브 SEO Description 제안 (자동완성) Request 모델"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id" : "019c739f-65fc-7d15-8c88-b31be00e588e"
|
||||
}
|
||||
}
|
||||
)
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
|
||||
class YoutubeDescriptionResponse(BaseModel):
|
||||
"""유튜브 SEO Description 제안 (자동완성) Response 모델"""
|
||||
title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
|
||||
description : str = Field(..., description="제안된 유튜브 SEO Description")
|
||||
keywords : list[str] = Field(..., description="해시태그 리스트")
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"title" : "여기에 더미 타이틀",
|
||||
"description": "여기에 더미 텍스트",
|
||||
"keywords": ["여기에", "더미", "해시태그"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 공통 응답 스키마
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""단순 메시지 응답"""
|
||||
|
||||
success: bool = Field(..., description="성공 여부")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "작업이 완료되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -1,742 +0,0 @@
|
|||
"""
|
||||
Social Account Service
|
||||
|
||||
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.utils.timezone import now
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from config import social_oauth_settings, db_settings
|
||||
from app.social.constants import SocialPlatform
|
||||
|
||||
# Social OAuth용 Redis 클라이언트 (DB 2 사용)
|
||||
redis_client = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=2,
|
||||
decode_responses=True,
|
||||
)
|
||||
from app.social.exceptions import (
|
||||
OAuthStateExpiredError,
|
||||
OAuthTokenRefreshError,
|
||||
SocialAccountNotFoundError,
|
||||
TokenExpiredError,
|
||||
)
|
||||
from app.social.oauth import get_oauth_client
|
||||
from app.social.schemas import (
|
||||
OAuthTokenResponse,
|
||||
PlatformUserInfo,
|
||||
SocialAccountResponse,
|
||||
SocialConnectResponse,
|
||||
)
|
||||
from app.user.models import SocialAccount
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SocialAccountService:
|
||||
"""
|
||||
소셜 계정 연동 서비스
|
||||
|
||||
OAuth 인증, 계정 연동/해제, 토큰 관리 기능을 제공합니다.
|
||||
"""
|
||||
|
||||
# Redis key prefix for OAuth state
|
||||
STATE_KEY_PREFIX = "social:oauth:state:"
|
||||
|
||||
async def start_connect(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
) -> SocialConnectResponse:
|
||||
"""
|
||||
소셜 계정 연동 시작
|
||||
|
||||
OAuth 인증 URL을 생성하고 state 토큰을 저장합니다.
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 연동할 플랫폼
|
||||
|
||||
Returns:
|
||||
SocialConnectResponse: OAuth 인증 URL 및 state 토큰
|
||||
"""
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 시작 - "
|
||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
# 1. state 토큰 생성 (CSRF 방지)
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# 2. state를 Redis에 저장 (user_uuid 포함)
|
||||
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
||||
state_data = {
|
||||
"user_uuid": user_uuid,
|
||||
"platform": platform.value,
|
||||
}
|
||||
await redis_client.setex(
|
||||
state_key,
|
||||
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
|
||||
json.dumps(state_data), # JSON으로 직렬화
|
||||
)
|
||||
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
|
||||
|
||||
# 3. OAuth 클라이언트에서 인증 URL 생성
|
||||
oauth_client = get_oauth_client(platform)
|
||||
auth_url = oauth_client.get_authorization_url(state)
|
||||
|
||||
logger.info(f"[SOCIAL] OAuth URL 생성 완료 - platform: {platform.value}")
|
||||
|
||||
return SocialConnectResponse(
|
||||
auth_url=auth_url,
|
||||
state=state,
|
||||
platform=platform.value,
|
||||
)
|
||||
|
||||
async def handle_callback(
|
||||
self,
|
||||
code: str,
|
||||
state: str,
|
||||
session: AsyncSession,
|
||||
) -> SocialAccountResponse:
|
||||
"""
|
||||
OAuth 콜백 처리
|
||||
|
||||
인가 코드로 토큰을 교환하고 소셜 계정을 저장합니다.
|
||||
|
||||
Args:
|
||||
code: OAuth 인가 코드
|
||||
state: CSRF 방지용 state 토큰
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccountResponse: 연동된 소셜 계정 정보
|
||||
|
||||
Raises:
|
||||
OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우
|
||||
"""
|
||||
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
|
||||
|
||||
# 1. state 검증 및 사용자 정보 추출
|
||||
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
||||
state_data_str = await redis_client.get(state_key)
|
||||
|
||||
if state_data_str is None:
|
||||
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
|
||||
raise OAuthStateExpiredError()
|
||||
|
||||
# state 데이터 파싱 (JSON 역직렬화)
|
||||
state_data = json.loads(state_data_str)
|
||||
user_uuid = state_data["user_uuid"]
|
||||
platform = SocialPlatform(state_data["platform"])
|
||||
|
||||
# state 삭제 (일회성)
|
||||
await redis_client.delete(state_key)
|
||||
logger.debug(f"[SOCIAL] state 토큰 사용 완료 및 삭제 - user_uuid: {user_uuid}")
|
||||
|
||||
# 2. OAuth 클라이언트로 토큰 교환
|
||||
oauth_client = get_oauth_client(platform)
|
||||
token_response = await oauth_client.exchange_code(code)
|
||||
|
||||
# 3. 플랫폼 사용자 정보 조회
|
||||
user_info = await oauth_client.get_user_info(token_response.access_token)
|
||||
|
||||
# 4. 기존 연동 확인 (소프트 삭제된 계정 포함)
|
||||
existing_account = await self._get_social_account(
|
||||
user_uuid=user_uuid,
|
||||
platform=platform,
|
||||
platform_user_id=user_info.platform_user_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if existing_account:
|
||||
# 기존 계정 존재 (활성화 또는 비활성화 상태)
|
||||
is_reactivation = False
|
||||
if existing_account.is_active and not existing_account.is_deleted:
|
||||
# 이미 활성화된 계정 - 토큰만 갱신
|
||||
logger.info(
|
||||
f"[SOCIAL] 기존 활성 계정 토큰 갱신 - "
|
||||
f"account_id: {existing_account.id}"
|
||||
)
|
||||
else:
|
||||
# 비활성화(소프트 삭제)된 계정 - 재활성화
|
||||
logger.info(
|
||||
f"[SOCIAL] 비활성 계정 재활성화 - "
|
||||
f"account_id: {existing_account.id}"
|
||||
)
|
||||
existing_account.is_active = True
|
||||
existing_account.is_deleted = False
|
||||
is_reactivation = True
|
||||
|
||||
# 토큰 및 정보 업데이트
|
||||
existing_account = await self._update_tokens(
|
||||
account=existing_account,
|
||||
token_response=token_response,
|
||||
user_info=user_info,
|
||||
session=session,
|
||||
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
|
||||
)
|
||||
return self._to_response(existing_account)
|
||||
|
||||
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
|
||||
social_account = await self._create_social_account(
|
||||
user_uuid=user_uuid,
|
||||
platform=platform,
|
||||
token_response=token_response,
|
||||
user_info=user_info,
|
||||
session=session,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 완료 - "
|
||||
f"account_id: {social_account.id}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
return self._to_response(social_account)
|
||||
|
||||
async def get_connected_accounts(
|
||||
self,
|
||||
user_uuid: str,
|
||||
session: AsyncSession,
|
||||
auto_refresh: bool = True,
|
||||
) -> list[SocialAccountResponse]:
|
||||
"""
|
||||
연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
session: DB 세션
|
||||
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
|
||||
|
||||
Returns:
|
||||
list[SocialAccountResponse]: 연동된 계정 목록
|
||||
"""
|
||||
logger.info(f"[SOCIAL] 연동 계정 목록 조회 - user_uuid: {user_uuid}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
|
||||
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
|
||||
|
||||
# 토큰 자동 갱신
|
||||
if auto_refresh:
|
||||
for account in accounts:
|
||||
await self._try_refresh_token(account, session)
|
||||
|
||||
return [self._to_response(account) for account in accounts]
|
||||
|
||||
async def refresh_all_tokens(
|
||||
self,
|
||||
user_uuid: str,
|
||||
session: AsyncSession,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
사용자의 모든 연동 계정 토큰 갱신 (로그인 시 호출)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
dict[str, bool]: 플랫폼별 갱신 성공 여부
|
||||
"""
|
||||
logger.info(f"[SOCIAL] 모든 연동 계정 토큰 갱신 시작 - user_uuid: {user_uuid}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
|
||||
refresh_results = {}
|
||||
for account in accounts:
|
||||
success = await self._try_refresh_token(account, session)
|
||||
refresh_results[f"{account.platform}_{account.id}"] = success
|
||||
|
||||
logger.info(f"[SOCIAL] 토큰 갱신 완료 - results: {refresh_results}")
|
||||
return refresh_results
|
||||
|
||||
async def _try_refresh_token(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
session: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
토큰 갱신 시도 (실패해도 예외 발생하지 않음)
|
||||
|
||||
Args:
|
||||
account: 소셜 계정
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
bool: 갱신 성공 여부
|
||||
"""
|
||||
# refresh_token이 없으면 갱신 불가
|
||||
if not account.refresh_token:
|
||||
logger.debug(
|
||||
f"[SOCIAL] refresh_token 없음, 갱신 스킵 - account_id: {account.id}"
|
||||
)
|
||||
return False
|
||||
|
||||
# 만료 시간 확인 (만료 1시간 전이면 갱신)
|
||||
should_refresh = False
|
||||
if account.token_expires_at is None:
|
||||
should_refresh = True
|
||||
else:
|
||||
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
|
||||
current_time = now().replace(tzinfo=None)
|
||||
buffer_time = current_time + timedelta(hours=1)
|
||||
if account.token_expires_at <= buffer_time:
|
||||
should_refresh = True
|
||||
|
||||
if not should_refresh:
|
||||
logger.debug(
|
||||
f"[SOCIAL] 토큰 아직 유효, 갱신 스킵 - account_id: {account.id}"
|
||||
)
|
||||
return True
|
||||
|
||||
# 갱신 시도
|
||||
try:
|
||||
await self._refresh_account_token(account, session)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[SOCIAL] 토큰 갱신 실패 (재연동 필요) - "
|
||||
f"account_id: {account.id}, error: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_account_by_platform(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
session: AsyncSession,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
특정 플랫폼의 연동 계정 조회
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 플랫폼
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccount: 소셜 계정 (없으면 None)
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.platform == platform.value,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_account_by_id(
|
||||
self,
|
||||
user_uuid: str,
|
||||
account_id: int,
|
||||
session: AsyncSession,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
account_id로 연동 계정 조회 (소유권 검증 포함)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
account_id: 소셜 계정 ID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccount: 소셜 계정 (없으면 None)
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.id == account_id,
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def disconnect_by_account_id(
|
||||
self,
|
||||
user_uuid: str,
|
||||
account_id: int,
|
||||
session: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
account_id로 소셜 계정 연동 해제
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
account_id: 소셜 계정 ID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
str: 연동 해제된 플랫폼 이름
|
||||
|
||||
Raises:
|
||||
SocialAccountNotFoundError: 연동된 계정이 없는 경우
|
||||
"""
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 해제 시작 (by account_id) - "
|
||||
f"user_uuid: {user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
|
||||
# 1. account_id로 계정 조회 (user_uuid 소유권 확인 포함)
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.id == account_id,
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if account is None:
|
||||
logger.warning(
|
||||
f"[SOCIAL] 연동된 계정 없음 - "
|
||||
f"user_uuid: {user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
raise SocialAccountNotFoundError()
|
||||
|
||||
# 2. 소프트 삭제
|
||||
platform = account.platform
|
||||
account.is_active = False
|
||||
account.is_deleted = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 해제 완료 - "
|
||||
f"account_id: {account.id}, platform: {platform}"
|
||||
)
|
||||
return platform
|
||||
|
||||
async def disconnect(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
session: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
소셜 계정 연동 해제 (platform 기준, deprecated)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 연동 해제할 플랫폼
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
|
||||
Raises:
|
||||
SocialAccountNotFoundError: 연동된 계정이 없는 경우
|
||||
"""
|
||||
logger.info(
|
||||
f"[SOCIAL] 소셜 계정 연동 해제 시작 - "
|
||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
|
||||
# 1. 연동된 계정 조회
|
||||
account = await self.get_account_by_platform(user_uuid, platform, session)
|
||||
|
||||
if account is None:
|
||||
logger.warning(
|
||||
f"[SOCIAL] 연동된 계정 없음 - "
|
||||
f"user_uuid: {user_uuid}, platform: {platform.value}"
|
||||
)
|
||||
raise SocialAccountNotFoundError(platform=platform.value)
|
||||
|
||||
# 2. 소프트 삭제 (토큰 폐기하지 않음 - 재연결 시 동의 화면 스킵을 위해)
|
||||
# 참고: 사용자가 완전히 앱 연결을 끊으려면 Google 계정 설정에서 직접 해제해야 함
|
||||
account.is_active = False
|
||||
account.is_deleted = True
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[SOCIAL] 소셜 계정 연동 해제 완료 - account_id: {account.id}")
|
||||
return True
|
||||
|
||||
async def ensure_valid_token(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
session: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
토큰 유효성 확인 및 필요시 갱신
|
||||
|
||||
Args:
|
||||
account: 소셜 계정
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
str: 유효한 access_token
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요)
|
||||
"""
|
||||
# 만료 시간 확인
|
||||
is_expired = False
|
||||
if account.token_expires_at is None:
|
||||
is_expired = True
|
||||
else:
|
||||
current_time = now().replace(tzinfo=None)
|
||||
buffer_time = current_time + timedelta(minutes=10)
|
||||
if account.token_expires_at <= buffer_time:
|
||||
is_expired = True
|
||||
|
||||
# 아직 유효하면 그대로 사용
|
||||
if not is_expired:
|
||||
return account.access_token
|
||||
|
||||
# 만료됐는데 refresh_token이 없으면 재연동 필요
|
||||
if not account.refresh_token:
|
||||
logger.warning(
|
||||
f"[SOCIAL] access_token 만료 + refresh_token 없음, 재연동 필요 - "
|
||||
f"account_id: {account.id}"
|
||||
)
|
||||
raise TokenExpiredError(platform=account.platform)
|
||||
|
||||
# refresh_token으로 갱신
|
||||
logger.info(
|
||||
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
||||
)
|
||||
return await self._refresh_account_token(account, session)
|
||||
|
||||
async def _refresh_account_token(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
session: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
계정 토큰 갱신
|
||||
|
||||
Args:
|
||||
account: 소셜 계정
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
str: 새 access_token
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: 갱신 실패 시 (재연동 필요)
|
||||
"""
|
||||
if not account.refresh_token:
|
||||
logger.warning(
|
||||
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}"
|
||||
)
|
||||
raise TokenExpiredError(platform=account.platform)
|
||||
|
||||
platform = SocialPlatform(account.platform)
|
||||
oauth_client = get_oauth_client(platform)
|
||||
|
||||
try:
|
||||
token_response = await oauth_client.refresh_token(account.refresh_token)
|
||||
except OAuthTokenRefreshError as e:
|
||||
logger.error(
|
||||
f"[SOCIAL] 토큰 갱신 실패, 재연동 필요 - "
|
||||
f"account_id: {account.id}, error: {e}"
|
||||
)
|
||||
raise TokenExpiredError(platform=account.platform)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[SOCIAL] 토큰 갱신 중 예외 발생, 재연동 필요 - "
|
||||
f"account_id: {account.id}, error: {e}"
|
||||
)
|
||||
raise TokenExpiredError(platform=account.platform)
|
||||
|
||||
# 토큰 업데이트
|
||||
account.access_token = token_response.access_token
|
||||
if token_response.refresh_token:
|
||||
account.refresh_token = token_response.refresh_token
|
||||
if token_response.expires_in:
|
||||
# DB에 naive datetime으로 저장 (MySQL DateTime은 timezone 미지원)
|
||||
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
|
||||
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
|
||||
return account.access_token
|
||||
|
||||
async def _get_social_account(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
platform_user_id: str,
|
||||
session: AsyncSession,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
소셜 계정 조회 (platform_user_id 포함)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 플랫폼
|
||||
platform_user_id: 플랫폼 사용자 ID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccount: 소셜 계정 (없으면 None)
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.platform == platform.value,
|
||||
SocialAccount.platform_user_id == platform_user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _create_social_account(
|
||||
self,
|
||||
user_uuid: str,
|
||||
platform: SocialPlatform,
|
||||
token_response: OAuthTokenResponse,
|
||||
user_info: PlatformUserInfo,
|
||||
session: AsyncSession,
|
||||
) -> SocialAccount:
|
||||
"""
|
||||
새 소셜 계정 생성
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
platform: 플랫폼
|
||||
token_response: OAuth 토큰 응답
|
||||
user_info: 플랫폼 사용자 정보
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
SocialAccount: 생성된 소셜 계정
|
||||
"""
|
||||
# 토큰 만료 시간 계산 (DB에 naive datetime으로 저장)
|
||||
token_expires_at = None
|
||||
if token_response.expires_in:
|
||||
token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user_uuid=user_uuid,
|
||||
platform=platform.value,
|
||||
access_token=token_response.access_token,
|
||||
refresh_token=token_response.refresh_token,
|
||||
token_expires_at=token_expires_at,
|
||||
scope=token_response.scope,
|
||||
platform_user_id=user_info.platform_user_id,
|
||||
platform_username=user_info.username,
|
||||
platform_data={
|
||||
"display_name": user_info.display_name,
|
||||
"profile_image_url": user_info.profile_image_url,
|
||||
**user_info.platform_data,
|
||||
},
|
||||
is_active=True,
|
||||
is_deleted=False,
|
||||
)
|
||||
|
||||
session.add(social_account)
|
||||
await session.commit()
|
||||
await session.refresh(social_account)
|
||||
|
||||
return social_account
|
||||
|
||||
async def _update_tokens(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
token_response: OAuthTokenResponse,
|
||||
user_info: PlatformUserInfo,
|
||||
session: AsyncSession,
|
||||
update_connected_at: bool = False,
|
||||
) -> SocialAccount:
|
||||
"""
|
||||
기존 계정 토큰 업데이트
|
||||
|
||||
Args:
|
||||
account: 기존 소셜 계정
|
||||
token_response: 새 OAuth 토큰 응답
|
||||
user_info: 플랫폼 사용자 정보
|
||||
session: DB 세션
|
||||
update_connected_at: 연결 시간 업데이트 여부 (재연결 시 True)
|
||||
|
||||
Returns:
|
||||
SocialAccount: 업데이트된 소셜 계정
|
||||
"""
|
||||
account.access_token = token_response.access_token
|
||||
if token_response.refresh_token:
|
||||
account.refresh_token = token_response.refresh_token
|
||||
if token_response.expires_in:
|
||||
# DB에 naive datetime으로 저장
|
||||
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
if token_response.scope:
|
||||
account.scope = token_response.scope
|
||||
|
||||
# 플랫폼 정보 업데이트
|
||||
account.platform_username = user_info.username
|
||||
account.platform_data = {
|
||||
"display_name": user_info.display_name,
|
||||
"profile_image_url": user_info.profile_image_url,
|
||||
**user_info.platform_data,
|
||||
}
|
||||
|
||||
# 재연결 시 연결 시간 업데이트
|
||||
if update_connected_at:
|
||||
account.connected_at = now().replace(tzinfo=None)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
|
||||
return account
|
||||
|
||||
def _to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
||||
"""
|
||||
SocialAccount를 SocialAccountResponse로 변환
|
||||
|
||||
Args:
|
||||
account: 소셜 계정
|
||||
|
||||
Returns:
|
||||
SocialAccountResponse: 응답 스키마
|
||||
"""
|
||||
platform_data = account.platform_data or {}
|
||||
|
||||
return SocialAccountResponse(
|
||||
id=account.id,
|
||||
platform=account.platform,
|
||||
platform_user_id=account.platform_user_id,
|
||||
platform_username=account.platform_username,
|
||||
display_name=platform_data.get("display_name"),
|
||||
profile_image_url=platform_data.get("profile_image_url"),
|
||||
is_active=account.is_active,
|
||||
connected_at=account.connected_at,
|
||||
platform_data=platform_data,
|
||||
)
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
social_account_service = SocialAccountService()
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
"""
|
||||
Social Uploader Module
|
||||
|
||||
소셜 미디어 영상 업로더 모듈입니다.
|
||||
"""
|
||||
|
||||
from app.social.constants import SocialPlatform
|
||||
from app.social.uploader.base import BaseSocialUploader, UploadResult
|
||||
|
||||
|
||||
def get_uploader(platform: SocialPlatform) -> BaseSocialUploader:
|
||||
"""
|
||||
플랫폼에 맞는 업로더 반환
|
||||
|
||||
Args:
|
||||
platform: 소셜 플랫폼
|
||||
|
||||
Returns:
|
||||
BaseSocialUploader: 업로더 인스턴스
|
||||
|
||||
Raises:
|
||||
ValueError: 지원하지 않는 플랫폼인 경우
|
||||
"""
|
||||
if platform == SocialPlatform.YOUTUBE:
|
||||
from app.social.uploader.youtube import YouTubeUploader
|
||||
|
||||
return YouTubeUploader()
|
||||
|
||||
# 추후 확장
|
||||
# elif platform == SocialPlatform.INSTAGRAM:
|
||||
# from app.social.uploader.instagram import InstagramUploader
|
||||
# return InstagramUploader()
|
||||
# elif platform == SocialPlatform.FACEBOOK:
|
||||
# from app.social.uploader.facebook import FacebookUploader
|
||||
# return FacebookUploader()
|
||||
# elif platform == SocialPlatform.TIKTOK:
|
||||
# from app.social.uploader.tiktok import TikTokUploader
|
||||
# return TikTokUploader()
|
||||
|
||||
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseSocialUploader",
|
||||
"UploadResult",
|
||||
"get_uploader",
|
||||
]
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
"""
|
||||
Base Social Uploader
|
||||
|
||||
소셜 미디어 영상 업로더의 추상 기본 클래스입니다.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.social.constants import PrivacyStatus, SocialPlatform
|
||||
|
||||
|
||||
@dataclass
|
||||
class UploadMetadata:
|
||||
"""
|
||||
업로드 메타데이터
|
||||
|
||||
영상 업로드 시 필요한 메타데이터를 정의합니다.
|
||||
|
||||
Attributes:
|
||||
title: 영상 제목
|
||||
description: 영상 설명
|
||||
tags: 태그 목록
|
||||
privacy_status: 공개 상태
|
||||
platform_options: 플랫폼별 추가 옵션
|
||||
"""
|
||||
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
privacy_status: PrivacyStatus = PrivacyStatus.PRIVATE
|
||||
platform_options: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UploadResult:
|
||||
"""
|
||||
업로드 결과
|
||||
|
||||
Attributes:
|
||||
success: 성공 여부
|
||||
platform_video_id: 플랫폼에서 부여한 영상 ID
|
||||
platform_url: 플랫폼에서의 영상 URL
|
||||
error_message: 에러 메시지 (실패 시)
|
||||
platform_response: 플랫폼 원본 응답 (디버깅용)
|
||||
"""
|
||||
|
||||
success: bool
|
||||
platform_video_id: Optional[str] = None
|
||||
platform_url: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
platform_response: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class BaseSocialUploader(ABC):
|
||||
"""
|
||||
소셜 미디어 영상 업로더 추상 기본 클래스
|
||||
|
||||
모든 플랫폼별 업로더는 이 클래스를 상속받아 구현합니다.
|
||||
|
||||
Attributes:
|
||||
platform: 소셜 플랫폼 종류
|
||||
"""
|
||||
|
||||
platform: SocialPlatform
|
||||
|
||||
@abstractmethod
|
||||
async def upload(
|
||||
self,
|
||||
video_path: str,
|
||||
access_token: str,
|
||||
metadata: UploadMetadata,
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> UploadResult:
|
||||
"""
|
||||
영상 업로드
|
||||
|
||||
Args:
|
||||
video_path: 업로드할 영상 파일 경로 (로컬 또는 URL)
|
||||
access_token: OAuth 액세스 토큰
|
||||
metadata: 업로드 메타데이터
|
||||
progress_callback: 진행률 콜백 함수 (0-100)
|
||||
|
||||
Returns:
|
||||
UploadResult: 업로드 결과
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_upload_status(
|
||||
self,
|
||||
platform_video_id: str,
|
||||
access_token: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
업로드 상태 조회
|
||||
|
||||
플랫폼에서 영상 처리 상태를 조회합니다.
|
||||
|
||||
Args:
|
||||
platform_video_id: 플랫폼 영상 ID
|
||||
access_token: OAuth 액세스 토큰
|
||||
|
||||
Returns:
|
||||
dict: 업로드 상태 정보
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete_video(
|
||||
self,
|
||||
platform_video_id: str,
|
||||
access_token: str,
|
||||
) -> bool:
|
||||
"""
|
||||
업로드된 영상 삭제
|
||||
|
||||
Args:
|
||||
platform_video_id: 플랫폼 영상 ID
|
||||
access_token: OAuth 액세스 토큰
|
||||
|
||||
Returns:
|
||||
bool: 삭제 성공 여부
|
||||
"""
|
||||
pass
|
||||
|
||||
def validate_metadata(self, metadata: UploadMetadata) -> None:
|
||||
"""
|
||||
메타데이터 유효성 검증
|
||||
|
||||
플랫폼별 제한사항을 확인합니다.
|
||||
|
||||
Args:
|
||||
metadata: 검증할 메타데이터
|
||||
|
||||
Raises:
|
||||
ValueError: 유효하지 않은 메타데이터
|
||||
"""
|
||||
if not metadata.title or len(metadata.title) == 0:
|
||||
raise ValueError("제목은 필수입니다.")
|
||||
|
||||
if len(metadata.title) > 100:
|
||||
raise ValueError("제목은 100자를 초과할 수 없습니다.")
|
||||
|
||||
if metadata.description and len(metadata.description) > 5000:
|
||||
raise ValueError("설명은 5000자를 초과할 수 없습니다.")
|
||||
|
||||
def get_video_url(self, platform_video_id: str) -> str:
|
||||
"""
|
||||
플랫폼 영상 URL 생성
|
||||
|
||||
Args:
|
||||
platform_video_id: 플랫폼 영상 ID
|
||||
|
||||
Returns:
|
||||
str: 영상 URL
|
||||
"""
|
||||
if self.platform == SocialPlatform.YOUTUBE:
|
||||
return f"https://www.youtube.com/watch?v={platform_video_id}"
|
||||
elif self.platform == SocialPlatform.INSTAGRAM:
|
||||
return f"https://www.instagram.com/reel/{platform_video_id}/"
|
||||
elif self.platform == SocialPlatform.FACEBOOK:
|
||||
return f"https://www.facebook.com/watch/?v={platform_video_id}"
|
||||
elif self.platform == SocialPlatform.TIKTOK:
|
||||
return f"https://www.tiktok.com/video/{platform_video_id}"
|
||||
else:
|
||||
return ""
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
"""
|
||||
YouTube Uploader
|
||||
|
||||
YouTube Data API v3를 사용한 영상 업로더입니다.
|
||||
Resumable Upload를 지원합니다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from config import social_upload_settings
|
||||
from app.social.constants import PrivacyStatus, SocialPlatform
|
||||
from app.social.exceptions import UploadError, UploadQuotaExceededError
|
||||
from app.social.uploader.base import BaseSocialUploader, UploadMetadata, UploadResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YouTubeUploader(BaseSocialUploader):
|
||||
"""
|
||||
YouTube 영상 업로더
|
||||
|
||||
YouTube Data API v3의 Resumable Upload를 사용하여
|
||||
대용량 영상을 안정적으로 업로드합니다.
|
||||
"""
|
||||
|
||||
platform = SocialPlatform.YOUTUBE
|
||||
|
||||
# YouTube API 엔드포인트
|
||||
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
|
||||
VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
|
||||
|
||||
# 청크 크기 (5MB - YouTube 권장)
|
||||
CHUNK_SIZE = 5 * 1024 * 1024
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.timeout = social_upload_settings.UPLOAD_TIMEOUT_SECONDS
|
||||
|
||||
async def upload(
|
||||
self,
|
||||
video_path: str,
|
||||
access_token: str,
|
||||
metadata: UploadMetadata,
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> UploadResult:
|
||||
"""
|
||||
YouTube에 영상 업로드 (Resumable Upload)
|
||||
|
||||
Args:
|
||||
video_path: 업로드할 영상 파일 경로
|
||||
access_token: OAuth 액세스 토큰
|
||||
metadata: 업로드 메타데이터
|
||||
progress_callback: 진행률 콜백 함수 (0-100)
|
||||
|
||||
Returns:
|
||||
UploadResult: 업로드 결과
|
||||
"""
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 업로드 시작 - video_path: {video_path}")
|
||||
|
||||
# 1. 메타데이터 유효성 검증
|
||||
self.validate_metadata(metadata)
|
||||
|
||||
# 2. 파일 크기 확인
|
||||
if not os.path.exists(video_path):
|
||||
logger.error(f"[YOUTUBE_UPLOAD] 파일 없음 - path: {video_path}")
|
||||
return UploadResult(
|
||||
success=False,
|
||||
error_message=f"파일을 찾을 수 없습니다: {video_path}",
|
||||
)
|
||||
|
||||
file_size = os.path.getsize(video_path)
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 파일 크기: {file_size / (1024*1024):.2f} MB")
|
||||
|
||||
try:
|
||||
# 3. Resumable upload 세션 시작
|
||||
upload_url = await self._init_resumable_upload(
|
||||
access_token=access_token,
|
||||
metadata=metadata,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
# 4. 파일 업로드
|
||||
video_id = await self._upload_file(
|
||||
upload_url=upload_url,
|
||||
video_path=video_path,
|
||||
file_size=file_size,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
video_url = self.get_video_url(video_id)
|
||||
|
||||
logger.info(
|
||||
f"[YOUTUBE_UPLOAD] 업로드 성공 - video_id: {video_id}, url: {video_url}"
|
||||
)
|
||||
|
||||
return UploadResult(
|
||||
success=True,
|
||||
platform_video_id=video_id,
|
||||
platform_url=video_url,
|
||||
)
|
||||
|
||||
except UploadQuotaExceededError:
|
||||
raise
|
||||
except UploadError as e:
|
||||
logger.error(f"[YOUTUBE_UPLOAD] 업로드 실패 - error: {e}")
|
||||
return UploadResult(
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[YOUTUBE_UPLOAD] 예상치 못한 에러 - error: {e}")
|
||||
return UploadResult(
|
||||
success=False,
|
||||
error_message=f"업로드 중 에러 발생: {str(e)}",
|
||||
)
|
||||
|
||||
async def _init_resumable_upload(
|
||||
self,
|
||||
access_token: str,
|
||||
metadata: UploadMetadata,
|
||||
file_size: int,
|
||||
) -> str:
|
||||
"""
|
||||
Resumable upload 세션 시작
|
||||
|
||||
Args:
|
||||
access_token: OAuth 액세스 토큰
|
||||
metadata: 업로드 메타데이터
|
||||
file_size: 파일 크기
|
||||
|
||||
Returns:
|
||||
str: 업로드 URL
|
||||
|
||||
Raises:
|
||||
UploadError: 세션 시작 실패
|
||||
"""
|
||||
logger.debug("[YOUTUBE_UPLOAD] Resumable upload 세션 시작")
|
||||
|
||||
# YouTube API 요청 본문
|
||||
body = {
|
||||
"snippet": {
|
||||
"title": metadata.title,
|
||||
"description": metadata.description or "",
|
||||
"tags": metadata.tags or [],
|
||||
"categoryId": self._get_category_id(metadata),
|
||||
},
|
||||
"status": {
|
||||
"privacyStatus": self._convert_privacy_status(metadata.privacy_status),
|
||||
"selfDeclaredMadeForKids": False,
|
||||
},
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"X-Upload-Content-Type": "video/*",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
}
|
||||
|
||||
params = {
|
||||
"uploadType": "resumable",
|
||||
"part": "snippet,status",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
self.UPLOAD_URL,
|
||||
params=params,
|
||||
headers=headers,
|
||||
json=body,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
upload_url = response.headers.get("location")
|
||||
if upload_url:
|
||||
logger.debug(
|
||||
f"[YOUTUBE_UPLOAD] 세션 시작 성공 - upload_url: {upload_url[:50]}..."
|
||||
)
|
||||
return upload_url
|
||||
|
||||
# 에러 처리
|
||||
error_data = response.json() if response.content else {}
|
||||
error_reason = (
|
||||
error_data.get("error", {}).get("errors", [{}])[0].get("reason", "")
|
||||
)
|
||||
|
||||
if error_reason == "quotaExceeded":
|
||||
logger.error("[YOUTUBE_UPLOAD] API 할당량 초과")
|
||||
raise UploadQuotaExceededError(platform=self.platform.value)
|
||||
|
||||
error_message = error_data.get("error", {}).get(
|
||||
"message", f"HTTP {response.status_code}"
|
||||
)
|
||||
logger.error(f"[YOUTUBE_UPLOAD] 세션 시작 실패 - error: {error_message}")
|
||||
raise UploadError(
|
||||
platform=self.platform.value,
|
||||
detail=f"Resumable upload 세션 시작 실패: {error_message}",
|
||||
)
|
||||
|
||||
async def _upload_file(
|
||||
self,
|
||||
upload_url: str,
|
||||
video_path: str,
|
||||
file_size: int,
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
파일 청크 업로드
|
||||
|
||||
Args:
|
||||
upload_url: Resumable upload URL
|
||||
video_path: 영상 파일 경로
|
||||
file_size: 파일 크기
|
||||
progress_callback: 진행률 콜백
|
||||
|
||||
Returns:
|
||||
str: YouTube 영상 ID
|
||||
|
||||
Raises:
|
||||
UploadError: 업로드 실패
|
||||
"""
|
||||
uploaded_bytes = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
with open(video_path, "rb") as video_file:
|
||||
while uploaded_bytes < file_size:
|
||||
# 청크 읽기
|
||||
chunk = video_file.read(self.CHUNK_SIZE)
|
||||
chunk_size = len(chunk)
|
||||
end_byte = uploaded_bytes + chunk_size - 1
|
||||
|
||||
headers = {
|
||||
"Content-Type": "video/*",
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Range": f"bytes {uploaded_bytes}-{end_byte}/{file_size}",
|
||||
}
|
||||
|
||||
response = await client.put(
|
||||
upload_url,
|
||||
headers=headers,
|
||||
content=chunk,
|
||||
)
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
# 업로드 완료
|
||||
result = response.json()
|
||||
video_id = result.get("id")
|
||||
if video_id:
|
||||
return video_id
|
||||
raise UploadError(
|
||||
platform=self.platform.value,
|
||||
detail="응답에서 video ID를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
elif response.status_code == 308:
|
||||
# 청크 업로드 성공, 계속 진행
|
||||
uploaded_bytes += chunk_size
|
||||
progress = int((uploaded_bytes / file_size) * 100)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(progress)
|
||||
|
||||
logger.debug(
|
||||
f"[YOUTUBE_UPLOAD] 청크 업로드 완료 - "
|
||||
f"progress: {progress}%, "
|
||||
f"uploaded: {uploaded_bytes}/{file_size}"
|
||||
)
|
||||
|
||||
else:
|
||||
# 에러
|
||||
error_data = response.json() if response.content else {}
|
||||
error_message = error_data.get("error", {}).get(
|
||||
"message", f"HTTP {response.status_code}"
|
||||
)
|
||||
logger.error(
|
||||
f"[YOUTUBE_UPLOAD] 청크 업로드 실패 - error: {error_message}"
|
||||
)
|
||||
raise UploadError(
|
||||
platform=self.platform.value,
|
||||
detail=f"청크 업로드 실패: {error_message}",
|
||||
)
|
||||
|
||||
raise UploadError(
|
||||
platform=self.platform.value,
|
||||
detail="업로드가 완료되지 않았습니다.",
|
||||
)
|
||||
|
||||
async def get_upload_status(
|
||||
self,
|
||||
platform_video_id: str,
|
||||
access_token: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
업로드 상태 조회
|
||||
|
||||
Args:
|
||||
platform_video_id: YouTube 영상 ID
|
||||
access_token: OAuth 액세스 토큰
|
||||
|
||||
Returns:
|
||||
dict: 업로드 상태 정보
|
||||
"""
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 상태 조회 - video_id: {platform_video_id}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
params = {
|
||||
"part": "status,processingDetails",
|
||||
"id": platform_video_id,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
self.VIDEOS_URL,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
items = data.get("items", [])
|
||||
|
||||
if items:
|
||||
item = items[0]
|
||||
status = item.get("status", {})
|
||||
processing = item.get("processingDetails", {})
|
||||
|
||||
return {
|
||||
"upload_status": status.get("uploadStatus"),
|
||||
"privacy_status": status.get("privacyStatus"),
|
||||
"processing_status": processing.get(
|
||||
"processingStatus", "processing"
|
||||
),
|
||||
"processing_progress": processing.get(
|
||||
"processingProgress", {}
|
||||
),
|
||||
}
|
||||
|
||||
return {"error": "영상을 찾을 수 없습니다."}
|
||||
|
||||
return {"error": f"상태 조회 실패: HTTP {response.status_code}"}
|
||||
|
||||
async def delete_video(
|
||||
self,
|
||||
platform_video_id: str,
|
||||
access_token: str,
|
||||
) -> bool:
|
||||
"""
|
||||
업로드된 영상 삭제
|
||||
|
||||
Args:
|
||||
platform_video_id: YouTube 영상 ID
|
||||
access_token: OAuth 액세스 토큰
|
||||
|
||||
Returns:
|
||||
bool: 삭제 성공 여부
|
||||
"""
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 - video_id: {platform_video_id}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
params = {"id": platform_video_id}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
self.VIDEOS_URL,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 성공 - video_id: {platform_video_id}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"[YOUTUBE_UPLOAD] 영상 삭제 실패 - "
|
||||
f"video_id: {platform_video_id}, status: {response.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
||||
"""
|
||||
PrivacyStatus를 YouTube API 형식으로 변환
|
||||
|
||||
Args:
|
||||
privacy_status: 공개 상태
|
||||
|
||||
Returns:
|
||||
str: YouTube API 공개 상태
|
||||
"""
|
||||
mapping = {
|
||||
PrivacyStatus.PUBLIC: "public",
|
||||
PrivacyStatus.UNLISTED: "unlisted",
|
||||
PrivacyStatus.PRIVATE: "private",
|
||||
}
|
||||
return mapping.get(privacy_status, "private")
|
||||
|
||||
def _get_category_id(self, metadata: UploadMetadata) -> str:
|
||||
"""
|
||||
카테고리 ID 추출
|
||||
|
||||
platform_options에서 category_id를 추출하거나 기본값 반환
|
||||
|
||||
Args:
|
||||
metadata: 업로드 메타데이터
|
||||
|
||||
Returns:
|
||||
str: YouTube 카테고리 ID
|
||||
"""
|
||||
if metadata.platform_options and "category_id" in metadata.platform_options:
|
||||
return str(metadata.platform_options["category_id"])
|
||||
|
||||
# 기본값: "22" (People & Blogs)
|
||||
return "22"
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
youtube_uploader = YouTubeUploader()
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
"""
|
||||
Social Worker Module
|
||||
|
||||
소셜 미디어 백그라운드 태스크 모듈입니다.
|
||||
"""
|
||||
|
||||
from app.social.worker.upload_task import process_social_upload
|
||||
|
||||
__all__ = ["process_social_upload"]
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
"""
|
||||
Social Upload Background Task
|
||||
|
||||
소셜 미디어 영상 업로드 백그라운드 태스크입니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
|
||||
from app.utils.timezone import now
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from config import social_upload_settings
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.social.constants import SocialPlatform, UploadStatus
|
||||
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
||||
from app.social.models import SocialUpload
|
||||
from app.social.services import social_account_service
|
||||
from app.social.uploader import get_uploader
|
||||
from app.social.uploader.base import UploadMetadata
|
||||
from app.user.models import SocialAccount
|
||||
from app.video.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _update_upload_status(
|
||||
upload_id: int,
|
||||
status: UploadStatus,
|
||||
upload_progress: int = 0,
|
||||
platform_video_id: Optional[str] = None,
|
||||
platform_url: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
업로드 상태 업데이트
|
||||
|
||||
Args:
|
||||
upload_id: SocialUpload ID
|
||||
status: 업로드 상태
|
||||
upload_progress: 업로드 진행률 (0-100)
|
||||
platform_video_id: 플랫폼 영상 ID
|
||||
platform_url: 플랫폼 영상 URL
|
||||
error_message: 에러 메시지
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if upload:
|
||||
upload.status = status.value
|
||||
upload.upload_progress = upload_progress
|
||||
|
||||
if platform_video_id:
|
||||
upload.platform_video_id = platform_video_id
|
||||
if platform_url:
|
||||
upload.platform_url = platform_url
|
||||
if error_message:
|
||||
upload.error_message = error_message
|
||||
if status == UploadStatus.COMPLETED:
|
||||
upload.uploaded_at = now().replace(tzinfo=None)
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[SOCIAL_UPLOAD] 상태 업데이트 - "
|
||||
f"upload_id: {upload_id}, status: {status.value}, progress: {upload_progress}%"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[SOCIAL_UPLOAD] DB 에러 - upload_id: {upload_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _download_video(video_url: str, upload_id: int) -> bytes:
|
||||
"""
|
||||
영상 파일 다운로드
|
||||
|
||||
Args:
|
||||
video_url: 영상 URL
|
||||
upload_id: 업로드 ID (로그용)
|
||||
|
||||
Returns:
|
||||
bytes: 영상 파일 내용
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: 다운로드 실패
|
||||
"""
|
||||
logger.info(f"[SOCIAL_UPLOAD] 영상 다운로드 시작 - upload_id: {upload_id}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
response = await client.get(video_url)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(
|
||||
f"[SOCIAL_UPLOAD] 영상 다운로드 완료 - "
|
||||
f"upload_id: {upload_id}, size: {len(response.content)} bytes"
|
||||
)
|
||||
return response.content
|
||||
|
||||
|
||||
async def _increment_retry_count(upload_id: int) -> int:
|
||||
"""
|
||||
재시도 횟수 증가
|
||||
|
||||
Args:
|
||||
upload_id: SocialUpload ID
|
||||
|
||||
Returns:
|
||||
int: 현재 재시도 횟수
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if upload:
|
||||
upload.retry_count += 1
|
||||
await session.commit()
|
||||
return upload.retry_count
|
||||
|
||||
return 0
|
||||
|
||||
except SQLAlchemyError:
|
||||
return 0
|
||||
|
||||
|
||||
async def process_social_upload(upload_id: int) -> None:
|
||||
"""
|
||||
소셜 미디어 업로드 처리
|
||||
|
||||
백그라운드에서 실행되며, 영상을 소셜 플랫폼에 업로드합니다.
|
||||
|
||||
Args:
|
||||
upload_id: SocialUpload ID
|
||||
"""
|
||||
logger.info(f"[SOCIAL_UPLOAD] 업로드 태스크 시작 - upload_id: {upload_id}")
|
||||
|
||||
temp_file_path: Optional[Path] = None
|
||||
|
||||
try:
|
||||
# 1. 업로드 정보 조회
|
||||
async with BackgroundSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SocialUpload).where(SocialUpload.id == upload_id)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
|
||||
if not upload:
|
||||
logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
|
||||
return
|
||||
|
||||
# 2. Video 정보 조회
|
||||
video_result = await session.execute(
|
||||
select(Video).where(Video.id == upload.video_id)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
|
||||
if not video or not video.result_movie_url:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - "
|
||||
f"upload_id: {upload_id}, video_id: {upload.video_id}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message="영상을 찾을 수 없거나 URL이 없습니다.",
|
||||
)
|
||||
return
|
||||
|
||||
# 3. SocialAccount 정보 조회
|
||||
account_result = await session.execute(
|
||||
select(SocialAccount).where(SocialAccount.id == upload.social_account_id)
|
||||
)
|
||||
account = account_result.scalar_one_or_none()
|
||||
|
||||
if not account or not account.is_active:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 소셜 계정 없음 또는 비활성화 - "
|
||||
f"upload_id: {upload_id}, account_id: {upload.social_account_id}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message="연동된 소셜 계정이 없거나 비활성화 상태입니다.",
|
||||
)
|
||||
return
|
||||
|
||||
# 필요한 정보 저장
|
||||
video_url = video.result_movie_url
|
||||
platform = SocialPlatform(upload.platform)
|
||||
upload_title = upload.title
|
||||
upload_description = upload.description
|
||||
upload_tags = upload.tags if isinstance(upload.tags, list) else None
|
||||
upload_privacy = upload.privacy_status
|
||||
upload_options = upload.platform_options
|
||||
|
||||
# 4. 상태 업데이트: uploading
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.UPLOADING,
|
||||
upload_progress=0,
|
||||
)
|
||||
|
||||
# 5. 토큰 유효성 확인 및 갱신
|
||||
async with BackgroundSessionLocal() as session:
|
||||
# account 다시 조회 (세션이 닫혔으므로)
|
||||
account_result = await session.execute(
|
||||
select(SocialAccount).where(SocialAccount.id == upload.social_account_id)
|
||||
)
|
||||
account = account_result.scalar_one_or_none()
|
||||
|
||||
if not account:
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message="소셜 계정을 찾을 수 없습니다.",
|
||||
)
|
||||
return
|
||||
|
||||
access_token = await social_account_service.ensure_valid_token(
|
||||
account=account,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 6. 영상 다운로드
|
||||
video_content = await _download_video(video_url, upload_id)
|
||||
|
||||
# 7. 임시 파일 저장
|
||||
temp_dir = Path(social_upload_settings.UPLOAD_TEMP_DIR) / str(upload_id)
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / "video.mp4"
|
||||
|
||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||
await f.write(video_content)
|
||||
|
||||
logger.info(
|
||||
f"[SOCIAL_UPLOAD] 임시 파일 저장 완료 - "
|
||||
f"upload_id: {upload_id}, path: {temp_file_path}"
|
||||
)
|
||||
|
||||
# 8. 메타데이터 준비
|
||||
from app.social.constants import PrivacyStatus
|
||||
|
||||
metadata = UploadMetadata(
|
||||
title=upload_title,
|
||||
description=upload_description,
|
||||
tags=upload_tags,
|
||||
privacy_status=PrivacyStatus(upload_privacy),
|
||||
platform_options=upload_options,
|
||||
)
|
||||
|
||||
# 9. 진행률 콜백 함수
|
||||
async def progress_callback(progress: int) -> None:
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.UPLOADING,
|
||||
upload_progress=progress,
|
||||
)
|
||||
|
||||
# 10. 플랫폼에 업로드
|
||||
uploader = get_uploader(platform)
|
||||
|
||||
# 동기 콜백으로 변환 (httpx 청크 업로드 내에서 호출되므로)
|
||||
def sync_progress_callback(progress: int) -> None:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(
|
||||
_update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.UPLOADING,
|
||||
upload_progress=progress,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = await uploader.upload(
|
||||
video_path=str(temp_file_path),
|
||||
access_token=access_token,
|
||||
metadata=metadata,
|
||||
progress_callback=sync_progress_callback,
|
||||
)
|
||||
|
||||
# 11. 결과 처리
|
||||
if result.success:
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.COMPLETED,
|
||||
upload_progress=100,
|
||||
platform_video_id=result.platform_video_id,
|
||||
platform_url=result.platform_url,
|
||||
)
|
||||
logger.info(
|
||||
f"[SOCIAL_UPLOAD] 업로드 완료 - "
|
||||
f"upload_id: {upload_id}, "
|
||||
f"platform_video_id: {result.platform_video_id}, "
|
||||
f"url: {result.platform_url}"
|
||||
)
|
||||
else:
|
||||
retry_count = await _increment_retry_count(upload_id)
|
||||
|
||||
if retry_count < social_upload_settings.UPLOAD_MAX_RETRIES:
|
||||
# 재시도 가능
|
||||
logger.warning(
|
||||
f"[SOCIAL_UPLOAD] 업로드 실패, 재시도 예정 - "
|
||||
f"upload_id: {upload_id}, retry: {retry_count}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.PENDING,
|
||||
upload_progress=0,
|
||||
error_message=f"업로드 실패 (재시도 {retry_count}/{social_upload_settings.UPLOAD_MAX_RETRIES}): {result.error_message}",
|
||||
)
|
||||
else:
|
||||
# 최대 재시도 초과
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message=f"최대 재시도 횟수 초과: {result.error_message}",
|
||||
)
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 업로드 최종 실패 - "
|
||||
f"upload_id: {upload_id}, error: {result.error_message}"
|
||||
)
|
||||
|
||||
except UploadQuotaExceededError as e:
|
||||
logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}")
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
||||
)
|
||||
|
||||
except TokenExpiredError as e:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 토큰 만료, 재연동 필요 - "
|
||||
f"upload_id: {upload_id}, platform: {e.platform}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message=f"{e.platform} 계정 인증이 만료되었습니다. 계정을 다시 연동해주세요.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
|
||||
f"upload_id: {upload_id}, error: {e}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message=f"업로드 중 에러 발생: {str(e)}",
|
||||
)
|
||||
|
||||
finally:
|
||||
# 임시 파일 정리
|
||||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
temp_file_path.parent.rmdir()
|
||||
logger.debug(f"[SOCIAL_UPLOAD] 임시 파일 삭제 - path: {temp_file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[SOCIAL_UPLOAD] 임시 파일 삭제 실패 - error: {e}")
|
||||
|
|
@ -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로 노래 생성 작업의 상태를 조회합니다.
|
||||
|
|
@ -407,7 +392,7 @@ async def get_song_status(
|
|||
# song_id로 Song 조회
|
||||
song_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == song_id)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
|
@ -415,6 +400,13 @@ async def get_song_status(
|
|||
|
||||
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
||||
if song and song.status == "processing":
|
||||
# store_name 조회
|
||||
project_result = await session.execute(
|
||||
select(Project).where(Project.id == song.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
store_name = project.store_name if project else "song"
|
||||
|
||||
# 상태를 uploading으로 변경 (중복 호출 방지)
|
||||
song.status = "uploading"
|
||||
song.suno_audio_id = first_clip.get("id")
|
||||
|
|
@ -426,13 +418,13 @@ async def get_song_status(
|
|||
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
|
||||
background_tasks.add_task(
|
||||
download_and_upload_song_by_suno_task_id,
|
||||
suno_task_id=song_id,
|
||||
suno_task_id=suno_task_id,
|
||||
audio_url=audio_url,
|
||||
user_uuid=current_user.user_uuid,
|
||||
store_name=store_name,
|
||||
duration=clip_duration,
|
||||
)
|
||||
logger.info(
|
||||
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}"
|
||||
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}"
|
||||
)
|
||||
|
||||
suno_audio_id = first_clip.get("id")
|
||||
|
|
@ -475,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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
|
@ -104,6 +107,21 @@ class GenerateSongResponse(BaseModel):
|
|||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class PollingSongRequest(BaseModel):
|
||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
||||
|
||||
Note:
|
||||
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"task_id": "abc123..."
|
||||
}
|
||||
"""
|
||||
|
||||
task_id: str = Field(..., description="Suno 작업 ID")
|
||||
|
||||
|
||||
class SongClipData(BaseModel):
|
||||
"""생성된 노래 클립 정보"""
|
||||
|
||||
|
|
@ -216,3 +234,94 @@ class PollingSongResponse(BaseModel):
|
|||
song_result_url: Optional[str] = Field(
|
||||
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dataclass Schemas (Legacy)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreData:
|
||||
id: int
|
||||
created_at: datetime
|
||||
store_name: str
|
||||
store_category: str | None = None
|
||||
store_region: str | None = None
|
||||
store_address: str | None = None
|
||||
store_phone_number: str | None = None
|
||||
store_info: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeData:
|
||||
id: int
|
||||
attr_category: str
|
||||
attr_value: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongSampleData:
|
||||
id: int
|
||||
ai: str
|
||||
ai_model: str
|
||||
sample_song: str
|
||||
season: str | None = None
|
||||
num_of_people: int | None = None
|
||||
people_category: str | None = None
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplateData:
|
||||
id: int
|
||||
prompt: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongFormData:
|
||||
store_name: str
|
||||
store_id: str
|
||||
prompts: str
|
||||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-5-mini"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||
form_data = await request.form()
|
||||
|
||||
# 고정 필드명들
|
||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||
|
||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||
lyrics_ids = []
|
||||
attributes = {}
|
||||
|
||||
for key, value in form_data.items():
|
||||
if key.startswith("lyrics-"):
|
||||
lyrics_id = key.split("-")[1]
|
||||
lyrics_ids.append(int(lyrics_id))
|
||||
elif key not in fixed_keys:
|
||||
attributes[key] = value
|
||||
|
||||
# attributes를 문자열로 변환
|
||||
attributes_str = (
|
||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||
if attributes
|
||||
else ""
|
||||
)
|
||||
|
||||
return cls(
|
||||
store_name=form_data.get("store_info_name", ""),
|
||||
store_id=form_data.get("store_id", ""),
|
||||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Song Background Tasks
|
|||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import traceback
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
|
|
@ -13,8 +15,10 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.song.models import Song
|
||||
from app.utils.common import generate_task_id
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from config import prj_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("song")
|
||||
|
|
@ -29,7 +33,6 @@ async def _update_song_status(
|
|||
song_url: str | None = None,
|
||||
suno_task_id: str | None = None,
|
||||
duration: float | None = None,
|
||||
song_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Song 테이블의 상태를 업데이트합니다.
|
||||
|
||||
|
|
@ -39,20 +42,13 @@ async def _update_song_status(
|
|||
song_url: 노래 URL
|
||||
suno_task_id: Suno task ID (선택)
|
||||
duration: 노래 길이 (선택)
|
||||
song_id: 특정 Song 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
"""
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
if song_id:
|
||||
# song_id로 특정 레코드 조회 (가장 정확한 식별)
|
||||
query_result = await session.execute(
|
||||
select(Song).where(Song.id == song_id)
|
||||
)
|
||||
elif suno_task_id:
|
||||
# suno_task_id로 조회 (Suno API 고유 ID)
|
||||
if suno_task_id:
|
||||
query_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
|
|
@ -60,7 +56,6 @@ async def _update_song_status(
|
|||
.limit(1)
|
||||
)
|
||||
else:
|
||||
# 기존 방식: task_id로 최신 레코드 조회 (비권장)
|
||||
query_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
|
|
@ -77,17 +72,17 @@ async def _update_song_status(
|
|||
if duration is not None:
|
||||
song.duration = duration
|
||||
await session.commit()
|
||||
logger.info(f"[Song] Status updated - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, status: {status}")
|
||||
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}")
|
||||
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
||||
return False
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
|
||||
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
|
||||
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -114,23 +109,85 @@ async def _download_audio(url: str, task_id: str) -> bytes:
|
|||
return response.content
|
||||
|
||||
|
||||
async def download_and_save_song(
|
||||
task_id: str,
|
||||
audio_url: str,
|
||||
store_name: str,
|
||||
) -> None:
|
||||
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||
|
||||
try:
|
||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
unique_id = await generate_task_id()
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
safe_store_name = "".join(
|
||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||
).strip()
|
||||
safe_store_name = safe_store_name or "song"
|
||||
file_name = f"{safe_store_name}.mp3"
|
||||
|
||||
# 절대 경로 생성
|
||||
media_dir = Path("media") / "song" / today / unique_id
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = media_dir / file_name
|
||||
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
|
||||
content = await _download_audio(audio_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(file_path), "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||
|
||||
# 프론트엔드에서 접근 가능한 URL 생성
|
||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
||||
base_url = f"{prj_settings.PROJECT_DOMAIN}"
|
||||
file_url = f"{base_url}{relative_path}"
|
||||
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(task_id, "completed", file_url)
|
||||
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
|
||||
async def download_and_upload_song_by_suno_task_id(
|
||||
suno_task_id: str,
|
||||
audio_url: str,
|
||||
user_uuid: str,
|
||||
store_name: str,
|
||||
duration: float | None = None,
|
||||
) -> None:
|
||||
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||
|
||||
파일명은 suno_task_id를 사용하여 고유성을 보장합니다.
|
||||
|
||||
Args:
|
||||
suno_task_id: Suno API 작업 ID (파일명으로도 사용)
|
||||
suno_task_id: Suno API 작업 ID
|
||||
audio_url: 다운로드할 오디오 URL
|
||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
duration: 노래 재생 시간 (초)
|
||||
"""
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, duration: {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}")
|
||||
temp_file_path: Path | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
|
|
@ -152,8 +209,12 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
task_id = song.task_id
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||
|
||||
# suno_task_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
|
||||
file_name = f"{suno_task_id}.mp3"
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
safe_store_name = "".join(
|
||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||
).strip()
|
||||
safe_store_name = safe_store_name or "song"
|
||||
file_name = f"{safe_store_name}.mp3"
|
||||
|
||||
# 임시 저장 경로 생성
|
||||
temp_dir = Path("media") / "temp" / task_id
|
||||
|
|
@ -172,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,15 +5,10 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
|
||||
from app.utils.timezone import now
|
||||
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
|
||||
|
|
@ -21,68 +16,19 @@ 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,
|
||||
KakaoLoginResponse,
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.user.services import auth_service, kakao_client
|
||||
from app.user.services.jwt import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_access_token_expire_seconds,
|
||||
get_refresh_token_expires_at,
|
||||
get_token_hash,
|
||||
)
|
||||
from app.social.services import social_account_service
|
||||
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",
|
||||
|
|
@ -143,19 +89,6 @@ async def kakao_callback(
|
|||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
|
||||
try:
|
||||
payload = decode_token(result.access_token)
|
||||
if payload and payload.get("sub"):
|
||||
user_uuid = payload.get("sub")
|
||||
await social_account_service.refresh_all_tokens(
|
||||
user_uuid=user_uuid,
|
||||
session=session,
|
||||
)
|
||||
except Exception as e:
|
||||
# 토큰 갱신 실패해도 로그인은 성공 처리
|
||||
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||
|
||||
# 프론트엔드로 토큰과 함께 리다이렉트
|
||||
redirect_url = (
|
||||
f"{prj_settings.PROJECT_DOMAIN}"
|
||||
|
|
@ -221,49 +154,32 @@ async def kakao_verify(
|
|||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
|
||||
try:
|
||||
payload = decode_token(result.access_token)
|
||||
if payload and payload.get("sub"):
|
||||
user_uuid = payload.get("sub")
|
||||
await social_account_service.refresh_all_tokens(
|
||||
user_uuid=user_uuid,
|
||||
session=session,
|
||||
)
|
||||
except Exception as e:
|
||||
# 토큰 갱신 실패해도 로그인은 성공 처리
|
||||
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||
|
||||
logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - is_new_user: {result.is_new_user}")
|
||||
logger.info(
|
||||
f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenResponse,
|
||||
summary="토큰 갱신 (Refresh Token Rotation)",
|
||||
description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.",
|
||||
response_model=AccessTokenResponse,
|
||||
summary="토큰 갱신",
|
||||
description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
|
||||
)
|
||||
async def refresh_token(
|
||||
body: RefreshTokenRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
) -> AccessTokenResponse:
|
||||
"""
|
||||
토큰 갱신 (Refresh Token Rotation)
|
||||
액세스 토큰 갱신
|
||||
|
||||
유효한 리프레시 토큰을 제출하면 새 액세스 토큰과 새 리프레시 토큰을 발급합니다.
|
||||
사용된 기존 리프레시 토큰은 즉시 폐기(revoke)됩니다.
|
||||
유효한 리프레시 토큰을 제출하면 새 액세스 토큰을 발급합니다.
|
||||
리프레시 토큰은 변경되지 않습니다.
|
||||
"""
|
||||
logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}")
|
||||
result = await auth_service.refresh_tokens(
|
||||
return await auth_service.refresh_tokens(
|
||||
refresh_token=body.refresh_token,
|
||||
session=session,
|
||||
)
|
||||
logger.info(
|
||||
f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, "
|
||||
f"new_refresh: ...{result.refresh_token[-20:]}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -271,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,
|
||||
|
|
@ -287,16 +199,11 @@ async def logout(
|
|||
현재 사용 중인 리프레시 토큰을 폐기합니다.
|
||||
해당 토큰으로는 더 이상 액세스 토큰을 갱신할 수 없습니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, "
|
||||
f"user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}"
|
||||
)
|
||||
await auth_service.logout(
|
||||
user_id=current_user.id,
|
||||
refresh_token=body.refresh_token,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -305,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),
|
||||
|
|
@ -320,15 +223,10 @@ async def logout_all(
|
|||
사용자의 모든 리프레시 토큰을 폐기합니다.
|
||||
모든 기기에서 재로그인이 필요합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, "
|
||||
f"user_uuid: {current_user.user_uuid}"
|
||||
)
|
||||
await auth_service.logout_all(
|
||||
user_id=current_user.id,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -337,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),
|
||||
|
|
@ -351,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 = now().replace(tzinfo=None)
|
||||
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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
"""
|
||||
SocialAccount API 라우터
|
||||
|
||||
소셜 계정 연동 CRUD 엔드포인트를 제공합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import User
|
||||
from app.user.schemas.social_account_schema import (
|
||||
SocialAccountCreateRequest,
|
||||
SocialAccountDeleteResponse,
|
||||
SocialAccountListResponse,
|
||||
SocialAccountResponse,
|
||||
SocialAccountUpdateRequest,
|
||||
)
|
||||
from app.user.services.social_account import SocialAccountService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/social-accounts", tags=["Social Account"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 소셜 계정 목록 조회
|
||||
# =============================================================================
|
||||
@router.get(
|
||||
"",
|
||||
response_model=SocialAccountListResponse,
|
||||
summary="소셜 계정 목록 조회",
|
||||
description="""
|
||||
## 개요
|
||||
현재 로그인한 사용자의 연동된 소셜 계정 목록을 조회합니다.
|
||||
|
||||
## 인증
|
||||
- Bearer 토큰 필수
|
||||
|
||||
## 반환 정보
|
||||
- **items**: 소셜 계정 목록
|
||||
- **total**: 총 계정 수
|
||||
""",
|
||||
)
|
||||
async def get_social_accounts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountListResponse:
|
||||
"""소셜 계정 목록 조회"""
|
||||
logger.info(f"[get_social_accounts] START - user_uuid: {current_user.user_uuid}")
|
||||
|
||||
try:
|
||||
service = SocialAccountService(session)
|
||||
accounts = await service.get_list(current_user)
|
||||
|
||||
response = SocialAccountListResponse(
|
||||
items=[SocialAccountResponse.model_validate(acc) for acc in accounts],
|
||||
total=len(accounts),
|
||||
)
|
||||
|
||||
logger.info(f"[get_social_accounts] SUCCESS - user_uuid: {current_user.user_uuid}, count: {len(accounts)}")
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[get_social_accounts] ERROR - user_uuid: {current_user.user_uuid}, error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="소셜 계정 목록 조회 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 소셜 계정 상세 조회
|
||||
# =============================================================================
|
||||
@router.get(
|
||||
"/{account_id}",
|
||||
response_model=SocialAccountResponse,
|
||||
summary="소셜 계정 상세 조회",
|
||||
description="""
|
||||
## 개요
|
||||
특정 소셜 계정의 상세 정보를 조회합니다.
|
||||
|
||||
## 인증
|
||||
- Bearer 토큰 필수
|
||||
- 본인 소유의 계정만 조회 가능
|
||||
|
||||
## 경로 파라미터
|
||||
- **account_id**: 소셜 계정 ID
|
||||
""",
|
||||
responses={
|
||||
404: {"description": "소셜 계정을 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_social_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountResponse:
|
||||
"""소셜 계정 상세 조회"""
|
||||
logger.info(f"[get_social_account] START - user_uuid: {current_user.user_uuid}, account_id: {account_id}")
|
||||
|
||||
try:
|
||||
service = SocialAccountService(session)
|
||||
account = await service.get_by_id(current_user, account_id)
|
||||
|
||||
if not account:
|
||||
logger.warning(f"[get_social_account] NOT_FOUND - account_id: {account_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="소셜 계정을 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
logger.info(f"[get_social_account] SUCCESS - account_id: {account_id}, platform: {account.platform}")
|
||||
return SocialAccountResponse.model_validate(account)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[get_social_account] ERROR - account_id: {account_id}, error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="소셜 계정 조회 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 소셜 계정 생성
|
||||
# =============================================================================
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SocialAccountResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="소셜 계정 연동",
|
||||
description="""
|
||||
## 개요
|
||||
새로운 소셜 계정을 연동합니다.
|
||||
|
||||
## 인증
|
||||
- Bearer 토큰 필수
|
||||
|
||||
## 요청 본문
|
||||
- **platform**: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
|
||||
- **access_token**: OAuth 액세스 토큰
|
||||
- **platform_user_id**: 플랫폼 내 사용자 고유 ID
|
||||
- 기타 선택 필드
|
||||
|
||||
## 주의사항
|
||||
- 동일한 플랫폼의 동일한 계정은 중복 연동할 수 없습니다.
|
||||
""",
|
||||
responses={
|
||||
400: {"description": "이미 연동된 계정"},
|
||||
},
|
||||
)
|
||||
async def create_social_account(
|
||||
data: SocialAccountCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountResponse:
|
||||
"""소셜 계정 연동"""
|
||||
logger.info(
|
||||
f"[create_social_account] START - user_uuid: {current_user.user_uuid}, "
|
||||
f"platform: {data.platform}, platform_user_id: {data.platform_user_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
service = SocialAccountService(session)
|
||||
account = await service.create(current_user, data)
|
||||
|
||||
logger.info(
|
||||
f"[create_social_account] SUCCESS - account_id: {account.id}, "
|
||||
f"platform: {account.platform}"
|
||||
)
|
||||
return SocialAccountResponse.model_validate(account)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"[create_social_account] DUPLICATE - error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[create_social_account] ERROR - error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="소셜 계정 연동 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 소셜 계정 수정
|
||||
# =============================================================================
|
||||
@router.patch(
|
||||
"/{account_id}",
|
||||
response_model=SocialAccountResponse,
|
||||
summary="소셜 계정 정보 수정",
|
||||
description="""
|
||||
## 개요
|
||||
소셜 계정 정보를 수정합니다. (토큰 갱신 등)
|
||||
|
||||
## 인증
|
||||
- Bearer 토큰 필수
|
||||
- 본인 소유의 계정만 수정 가능
|
||||
|
||||
## 경로 파라미터
|
||||
- **account_id**: 소셜 계정 ID
|
||||
|
||||
## 요청 본문
|
||||
- 수정할 필드만 전송 (PATCH 방식)
|
||||
""",
|
||||
responses={
|
||||
404: {"description": "소셜 계정을 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def update_social_account(
|
||||
account_id: int,
|
||||
data: SocialAccountUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountResponse:
|
||||
"""소셜 계정 정보 수정"""
|
||||
logger.info(
|
||||
f"[update_social_account] START - user_uuid: {current_user.user_uuid}, "
|
||||
f"account_id: {account_id}, data: {data.model_dump(exclude_unset=True)}"
|
||||
)
|
||||
|
||||
try:
|
||||
service = SocialAccountService(session)
|
||||
account = await service.update(current_user, account_id, data)
|
||||
|
||||
if not account:
|
||||
logger.warning(f"[update_social_account] NOT_FOUND - account_id: {account_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="소셜 계정을 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
logger.info(f"[update_social_account] SUCCESS - account_id: {account_id}")
|
||||
return SocialAccountResponse.model_validate(account)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[update_social_account] ERROR - account_id: {account_id}, error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="소셜 계정 수정 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 소셜 계정 삭제
|
||||
# =============================================================================
|
||||
@router.delete(
|
||||
"/{account_id}",
|
||||
response_model=SocialAccountDeleteResponse,
|
||||
summary="소셜 계정 연동 해제",
|
||||
description="""
|
||||
## 개요
|
||||
소셜 계정 연동을 해제합니다. (소프트 삭제)
|
||||
|
||||
## 인증
|
||||
- Bearer 토큰 필수
|
||||
- 본인 소유의 계정만 삭제 가능
|
||||
|
||||
## 경로 파라미터
|
||||
- **account_id**: 소셜 계정 ID
|
||||
""",
|
||||
responses={
|
||||
404: {"description": "소셜 계정을 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def delete_social_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SocialAccountDeleteResponse:
|
||||
"""소셜 계정 연동 해제"""
|
||||
logger.info(f"[delete_social_account] START - user_uuid: {current_user.user_uuid}, account_id: {account_id}")
|
||||
|
||||
try:
|
||||
service = SocialAccountService(session)
|
||||
deleted_id = await service.delete(current_user, account_id)
|
||||
|
||||
if not deleted_id:
|
||||
logger.warning(f"[delete_social_account] NOT_FOUND - account_id: {account_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="소셜 계정을 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
logger.info(f"[delete_social_account] SUCCESS - deleted_id: {deleted_id}")
|
||||
return SocialAccountDeleteResponse(
|
||||
message="소셜 계정이 삭제되었습니다.",
|
||||
deleted_id=deleted_id,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[delete_social_account] ERROR - account_id: {account_id}, error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="소셜 계정 삭제 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
|
@ -45,7 +45,7 @@ class UserAdmin(ModelView, model=User):
|
|||
form_excluded_columns = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"projects",
|
||||
"user_projects",
|
||||
"refresh_tokens",
|
||||
"social_accounts",
|
||||
]
|
||||
|
|
@ -160,17 +160,16 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
|||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_username",
|
||||
"is_active",
|
||||
"is_deleted",
|
||||
"created_at",
|
||||
"connected_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_user_id",
|
||||
"platform_username",
|
||||
|
|
@ -178,34 +177,32 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
|||
"scope",
|
||||
"token_expires_at",
|
||||
"is_active",
|
||||
"is_deleted",
|
||||
"created_at",
|
||||
"connected_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at", "updated_at", "user"]
|
||||
form_excluded_columns = ["connected_at", "updated_at", "user"]
|
||||
|
||||
column_searchable_list = [
|
||||
SocialAccount.user_uuid,
|
||||
SocialAccount.user_id,
|
||||
SocialAccount.platform,
|
||||
SocialAccount.platform_user_id,
|
||||
SocialAccount.platform_username,
|
||||
]
|
||||
|
||||
column_default_sort = (SocialAccount.created_at, True)
|
||||
column_default_sort = (SocialAccount.connected_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
SocialAccount.id,
|
||||
SocialAccount.user_uuid,
|
||||
SocialAccount.user_id,
|
||||
SocialAccount.platform,
|
||||
SocialAccount.is_active,
|
||||
SocialAccount.is_deleted,
|
||||
SocialAccount.created_at,
|
||||
SocialAccount.connected_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_uuid": "사용자 UUID",
|
||||
"user_id": "사용자 ID",
|
||||
"platform": "플랫폼",
|
||||
"platform_user_id": "플랫폼 사용자 ID",
|
||||
"platform_username": "플랫폼 사용자명",
|
||||
|
|
@ -213,7 +210,6 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
|||
"scope": "권한 범위",
|
||||
"token_expires_at": "토큰 만료일시",
|
||||
"is_active": "활성화",
|
||||
"is_deleted": "삭제됨",
|
||||
"created_at": "생성일시",
|
||||
"connected_at": "연동일시",
|
||||
"updated_at": "수정일시",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends
|
||||
|
|
@ -13,18 +12,17 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.user.models import User
|
||||
from app.user.services.auth import (
|
||||
from app.user.exceptions import (
|
||||
AdminRequiredError,
|
||||
InvalidTokenError,
|
||||
MissingTokenError,
|
||||
TokenExpiredError,
|
||||
UserInactiveError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from app.user.models import User
|
||||
from app.user.services.jwt import decode_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
|
|
@ -50,52 +48,35 @@ async def get_current_user(
|
|||
UserInactiveError: 비활성화된 계정인 경우
|
||||
"""
|
||||
if credentials is None:
|
||||
logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError")
|
||||
raise MissingTokenError()
|
||||
|
||||
token = credentials.credentials
|
||||
logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}")
|
||||
|
||||
payload = decode_token(token)
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
|
||||
raise InvalidTokenError()
|
||||
|
||||
# 토큰 타입 확인
|
||||
if payload.get("type") != "access":
|
||||
logger.warning(
|
||||
f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, "
|
||||
f"got: {payload.get('type')}, sub: {payload.get('sub')}"
|
||||
)
|
||||
raise InvalidTokenError("액세스 토큰이 아닙니다.")
|
||||
|
||||
user_uuid = payload.get("sub")
|
||||
if user_uuid is None:
|
||||
logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}")
|
||||
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
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}")
|
||||
raise UserNotFoundError()
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||
)
|
||||
raise UserInactiveError()
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
|
@ -116,43 +97,30 @@ async def get_current_user_optional(
|
|||
User | None: 로그인한 사용자 또는 None
|
||||
"""
|
||||
if credentials is None:
|
||||
logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음")
|
||||
return None
|
||||
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}")
|
||||
return None
|
||||
|
||||
if payload.get("type") != "access":
|
||||
logger.debug(
|
||||
f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})"
|
||||
)
|
||||
return None
|
||||
|
||||
user_uuid = payload.get("sub")
|
||||
if user_uuid is None:
|
||||
logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음")
|
||||
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
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not user.is_active:
|
||||
logger.debug(
|
||||
f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
User 모듈 커스텀 예외 정의
|
||||
|
||||
인증 및 사용자 관련 에러를 처리하기 위한 예외 클래스들입니다.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class AuthException(HTTPException):
|
||||
"""인증 관련 기본 예외"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
code: str,
|
||||
message: str,
|
||||
):
|
||||
super().__init__(
|
||||
status_code=status_code,
|
||||
detail={"code": code, "message": message},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 카카오 OAuth 관련 예외
|
||||
# =============================================================================
|
||||
class InvalidAuthCodeError(AuthException):
|
||||
"""유효하지 않은 인가 코드"""
|
||||
|
||||
def __init__(self, message: str = "유효하지 않은 인가 코드입니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="INVALID_CODE",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class KakaoAuthFailedError(AuthException):
|
||||
"""카카오 인증 실패"""
|
||||
|
||||
def __init__(self, message: str = "카카오 인증에 실패했습니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="KAKAO_AUTH_FAILED",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class KakaoAPIError(AuthException):
|
||||
"""카카오 API 호출 오류"""
|
||||
|
||||
def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="KAKAO_API_ERROR",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JWT 토큰 관련 예외
|
||||
# =============================================================================
|
||||
class TokenExpiredError(AuthException):
|
||||
"""토큰 만료"""
|
||||
|
||||
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_EXPIRED",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class InvalidTokenError(AuthException):
|
||||
"""유효하지 않은 토큰"""
|
||||
|
||||
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="INVALID_TOKEN",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class TokenRevokedError(AuthException):
|
||||
"""취소된 토큰"""
|
||||
|
||||
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_REVOKED",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class MissingTokenError(AuthException):
|
||||
"""토큰 누락"""
|
||||
|
||||
def __init__(self, message: str = "인증 토큰이 필요합니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="MISSING_TOKEN",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 사용자 관련 예외
|
||||
# =============================================================================
|
||||
class UserNotFoundError(AuthException):
|
||||
"""사용자 없음"""
|
||||
|
||||
def __init__(self, message: str = "사용자를 찾을 수 없습니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="USER_NOT_FOUND",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class UserInactiveError(AuthException):
|
||||
"""비활성화된 계정"""
|
||||
|
||||
def __init__(self, message: str = "비활성화된 계정입니다. 관리자에게 문의하세요."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
code="USER_INACTIVE",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class AdminRequiredError(AuthException):
|
||||
"""관리자 권한 필요"""
|
||||
|
||||
def __init__(self, message: str = "관리자 권한이 필요합니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
code="ADMIN_REQUIRED",
|
||||
message=message,
|
||||
)
|
||||
|
|
@ -5,7 +5,6 @@ User 모듈 SQLAlchemy 모델 정의
|
|||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
|
|
@ -14,9 +13,8 @@ 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):
|
||||
|
|
@ -28,7 +26,6 @@ class User(Base):
|
|||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
kakao_id: 카카오 고유 ID (필수, 유니크)
|
||||
user_uuid: 사용자 식별을 위한 UUID7 (필수, 유니크)
|
||||
email: 이메일 주소 (선택, 카카오에서 제공 시)
|
||||
nickname: 카카오 닉네임 (선택)
|
||||
profile_image_url: 카카오 프로필 이미지 URL (선택)
|
||||
|
|
@ -56,7 +53,8 @@ class User(Base):
|
|||
- 실제 데이터는 DB에 유지됨
|
||||
|
||||
Relationships:
|
||||
projects: 사용자가 소유한 프로젝트 목록 (1:N 관계)
|
||||
user_projects: Project와의 M:N 관계 (중계 테이블 통한 연결)
|
||||
projects: 사용자가 참여한 프로젝트 목록 (Association Proxy)
|
||||
|
||||
카카오 API 응답 필드 매핑:
|
||||
- kakao_id: id (카카오 회원번호)
|
||||
|
|
@ -70,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"),
|
||||
|
|
@ -106,13 +103,6 @@ class User(Base):
|
|||
comment="카카오 고유 ID (회원번호)",
|
||||
)
|
||||
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="사용자 식별을 위한 UUID7 (시간순 정렬 가능한 UUID)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 카카오에서 제공하는 사용자 정보 (선택적)
|
||||
# ==========================================================================
|
||||
|
|
@ -232,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",
|
||||
)
|
||||
|
||||
|
|
@ -291,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)
|
||||
|
|
@ -309,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"),
|
||||
|
|
@ -335,15 +324,10 @@ 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,
|
||||
unique=True,
|
||||
comment="리프레시 토큰 SHA-256 해시값",
|
||||
)
|
||||
|
||||
|
|
@ -391,7 +375,6 @@ class RefreshToken(Base):
|
|||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="refresh_tokens",
|
||||
lazy="selectin", # lazy loading 방지
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
|
@ -405,15 +388,6 @@ class RefreshToken(Base):
|
|||
)
|
||||
|
||||
|
||||
class Platform(str, Enum):
|
||||
"""소셜 플랫폼 구분"""
|
||||
|
||||
YOUTUBE = "youtube"
|
||||
INSTAGRAM = "instagram"
|
||||
FACEBOOK = "facebook"
|
||||
TIKTOK = "tiktok"
|
||||
|
||||
|
||||
class SocialAccount(Base):
|
||||
"""
|
||||
소셜 계정 연동 테이블
|
||||
|
|
@ -447,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,
|
||||
|
|
@ -476,20 +449,20 @@ 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 참조)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 플랫폼 구분
|
||||
# ==========================================================================
|
||||
platform: Mapped[Platform] = mapped_column(
|
||||
platform: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)",
|
||||
comment="플랫폼 구분 (youtube, instagram, facebook)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
|
|
@ -522,9 +495,9 @@ class SocialAccount(Base):
|
|||
# ==========================================================================
|
||||
# 플랫폼 계정 식별 정보
|
||||
# ==========================================================================
|
||||
platform_user_id: Mapped[Optional[str]] = mapped_column(
|
||||
platform_user_id: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
comment="플랫폼 내 사용자 고유 ID",
|
||||
)
|
||||
|
||||
|
|
@ -550,14 +523,7 @@ class SocialAccount(Base):
|
|||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="활성화 상태 (비활성화 시 사용 중지)",
|
||||
)
|
||||
|
||||
is_deleted: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="소프트 삭제 여부 (True: 삭제됨)",
|
||||
comment="연동 활성화 상태 (비활성화 시 사용 중지)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
|
|
@ -565,12 +531,11 @@ class SocialAccount(Base):
|
|||
# ==========================================================================
|
||||
connected_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
comment="연결 일시",
|
||||
comment="연동 일시",
|
||||
)
|
||||
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
|
|
@ -579,27 +544,19 @@ class SocialAccount(Base):
|
|||
comment="정보 수정 일시",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# User 관계
|
||||
# ==========================================================================
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="social_accounts",
|
||||
lazy="selectin", # lazy loading 방지
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoCodeRequest,
|
||||
KakaoLoginResponse,
|
||||
KakaoTokenResponse,
|
||||
|
|
@ -11,6 +12,7 @@ from app.user.schemas.user_schema import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"AccessTokenResponse",
|
||||
"KakaoCodeRequest",
|
||||
"KakaoLoginResponse",
|
||||
"KakaoTokenResponse",
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
"""
|
||||
SocialAccount 모듈 Pydantic 스키마 정의
|
||||
|
||||
소셜 계정 연동 API 요청/응답 검증을 위한 스키마들입니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.user.models import Platform
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 요청 스키마
|
||||
# =============================================================================
|
||||
class SocialAccountCreateRequest(BaseModel):
|
||||
"""소셜 계정 연동 요청"""
|
||||
|
||||
platform: Platform = Field(..., description="플랫폼 구분 (youtube, instagram, facebook, tiktok)")
|
||||
access_token: str = Field(..., min_length=1, description="OAuth 액세스 토큰")
|
||||
refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰")
|
||||
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||
platform_user_id: Optional[str] = Field(None, description="플랫폼 내 사용자 고유 ID")
|
||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"platform": "instagram",
|
||||
"access_token": "IGQWRPcG...",
|
||||
"refresh_token": None,
|
||||
"token_expires_at": None,
|
||||
"scope": None,
|
||||
"platform_user_id": None,
|
||||
"platform_username": None,
|
||||
"platform_data": None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountUpdateRequest(BaseModel):
|
||||
"""소셜 계정 정보 수정 요청"""
|
||||
|
||||
access_token: Optional[str] = Field(None, min_length=1, description="OAuth 액세스 토큰")
|
||||
refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰")
|
||||
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||
is_active: Optional[bool] = Field(None, description="활성화 상태")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"access_token": "IGQWRPcG_NEW_TOKEN...",
|
||||
"token_expires_at": "2026-04-15T10:30:00",
|
||||
"is_active": True
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 응답 스키마
|
||||
# =============================================================================
|
||||
class SocialAccountResponse(BaseModel):
|
||||
"""소셜 계정 정보 응답"""
|
||||
|
||||
account_id: int = Field(..., validation_alias="id", description="소셜 계정 ID")
|
||||
platform: Platform = Field(..., description="플랫폼 구분")
|
||||
platform_user_id: Optional[str] = Field(None, description="플랫폼 내 사용자 고유 ID")
|
||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||
is_active: bool = Field(..., description="활성화 상태")
|
||||
created_at: datetime = Field(..., description="연동 일시")
|
||||
updated_at: datetime = Field(..., description="수정 일시")
|
||||
|
||||
model_config = {
|
||||
"from_attributes": True,
|
||||
"populate_by_name": True,
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"account_id": 1,
|
||||
"platform": "instagram",
|
||||
"platform_user_id": "17841400000000000",
|
||||
"platform_username": "my_instagram_account",
|
||||
"platform_data": {
|
||||
"business_account_id": "17841400000000000"
|
||||
},
|
||||
"scope": "instagram_basic,instagram_content_publish",
|
||||
"token_expires_at": "2026-03-15T10:30:00",
|
||||
"is_active": True,
|
||||
"created_at": "2026-01-15T10:30:00",
|
||||
"updated_at": "2026-01-15T10:30:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountListResponse(BaseModel):
|
||||
"""소셜 계정 목록 응답"""
|
||||
|
||||
items: list[SocialAccountResponse] = Field(..., description="소셜 계정 목록")
|
||||
total: int = Field(..., description="총 계정 수")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"items": [
|
||||
{
|
||||
"account_id": 1,
|
||||
"platform": "instagram",
|
||||
"platform_user_id": "17841400000000000",
|
||||
"platform_username": "my_instagram_account",
|
||||
"platform_data": None,
|
||||
"scope": "instagram_basic",
|
||||
"token_expires_at": "2026-03-15T10:30:00",
|
||||
"is_active": True,
|
||||
"created_at": "2026-01-15T10:30:00",
|
||||
"updated_at": "2026-01-15T10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountDeleteResponse(BaseModel):
|
||||
"""소셜 계정 삭제 응답"""
|
||||
|
||||
message: str = Field(..., description="결과 메시지")
|
||||
deleted_id: int = Field(..., description="삭제된 계정 ID")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"message": "소셜 계정이 삭제되었습니다.",
|
||||
"deleted_id": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,24 @@ class TokenResponse(BaseModel):
|
|||
}
|
||||
|
||||
|
||||
class AccessTokenResponse(BaseModel):
|
||||
"""액세스 토큰 갱신 응답"""
|
||||
|
||||
access_token: str = Field(..., description="액세스 토큰")
|
||||
token_type: str = Field(default="Bearer", description="토큰 타입")
|
||||
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""토큰 갱신 요청"""
|
||||
|
||||
|
|
@ -138,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 = {
|
||||
|
|
@ -154,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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,85 +5,29 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.utils.timezone import now
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import prj_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 인증 예외 클래스 정의
|
||||
# =============================================================================
|
||||
class AuthException(HTTPException):
|
||||
"""인증 관련 기본 예외"""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str):
|
||||
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
class TokenExpiredError(AuthException):
|
||||
"""토큰 만료"""
|
||||
|
||||
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_EXPIRED", message)
|
||||
|
||||
|
||||
class InvalidTokenError(AuthException):
|
||||
"""유효하지 않은 토큰"""
|
||||
|
||||
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "INVALID_TOKEN", message)
|
||||
|
||||
|
||||
class TokenRevokedError(AuthException):
|
||||
"""취소된 토큰"""
|
||||
|
||||
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_REVOKED", message)
|
||||
|
||||
|
||||
class MissingTokenError(AuthException):
|
||||
"""토큰 누락"""
|
||||
|
||||
def __init__(self, message: str = "인증 토큰이 필요합니다."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "MISSING_TOKEN", message)
|
||||
|
||||
|
||||
class UserNotFoundError(AuthException):
|
||||
"""사용자 없음"""
|
||||
|
||||
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
|
||||
super().__init__(status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", message)
|
||||
|
||||
|
||||
class UserInactiveError(AuthException):
|
||||
"""비활성화된 계정"""
|
||||
|
||||
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
|
||||
super().__init__(status.HTTP_403_FORBIDDEN, "USER_INACTIVE", message)
|
||||
|
||||
|
||||
class AdminRequiredError(AuthException):
|
||||
"""관리자 권한 필요"""
|
||||
|
||||
def __init__(self, message: str = "관리자 권한이 필요합니다."):
|
||||
super().__init__(status.HTTP_403_FORBIDDEN, "ADMIN_REQUIRED", message)
|
||||
|
||||
|
||||
from app.user.exceptions import (
|
||||
InvalidTokenError,
|
||||
TokenExpiredError,
|
||||
TokenRevokedError,
|
||||
UserInactiveError,
|
||||
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,
|
||||
TokenResponse,
|
||||
UserBriefResponse,
|
||||
)
|
||||
from app.user.services.jwt import (
|
||||
create_access_token,
|
||||
|
|
@ -151,28 +95,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 = now().replace(tzinfo=None)
|
||||
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(
|
||||
|
|
@ -180,7 +123,13 @@ class AuthService:
|
|||
refresh_token=refresh_token,
|
||||
token_type="Bearer",
|
||||
expires_in=get_access_token_expire_seconds(),
|
||||
is_new_user=is_new_user,
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -188,129 +137,59 @@ class AuthService:
|
|||
self,
|
||||
refresh_token: str,
|
||||
session: AsyncSession,
|
||||
) -> TokenResponse:
|
||||
) -> AccessTokenResponse:
|
||||
"""
|
||||
리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation)
|
||||
|
||||
기존 리프레시 토큰을 폐기하고, 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다.
|
||||
사용자가 서비스를 지속 사용하는 한 세션이 자동 유지됩니다.
|
||||
리프레시 토큰으로 액세스 토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
TokenResponse: 새 액세스 토큰 + 새 리프레시 토큰
|
||||
AccessTokenResponse: 새 액세스 토큰
|
||||
|
||||
Raises:
|
||||
InvalidTokenError: 토큰이 유효하지 않은 경우
|
||||
TokenExpiredError: 토큰이 만료된 경우
|
||||
TokenRevokedError: 토큰이 폐기된 경우
|
||||
"""
|
||||
logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}")
|
||||
|
||||
# 1. 토큰 디코딩 및 검증
|
||||
payload = decode_token(refresh_token)
|
||||
if payload is None:
|
||||
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
|
||||
raise InvalidTokenError()
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, "
|
||||
f"sub: {payload.get('sub')}"
|
||||
)
|
||||
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, "
|
||||
f"exp: {payload.get('exp')}"
|
||||
)
|
||||
|
||||
# 2. DB에서 리프레시 토큰 조회
|
||||
token_hash = get_token_hash(refresh_token)
|
||||
db_token = await self._get_refresh_token_by_hash(token_hash, session)
|
||||
|
||||
if db_token is None:
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, "
|
||||
f"token_hash: {token_hash[:16]}..."
|
||||
)
|
||||
raise InvalidTokenError()
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., "
|
||||
f"user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, "
|
||||
f"expires_at: {db_token.expires_at}"
|
||||
)
|
||||
|
||||
# 3. 토큰 상태 확인
|
||||
if db_token.is_revoked:
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), "
|
||||
f"token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, "
|
||||
f"revoked_at: {db_token.revoked_at}"
|
||||
)
|
||||
raise TokenRevokedError()
|
||||
|
||||
# 4. 만료 확인
|
||||
if db_token.expires_at < now().replace(tzinfo=None):
|
||||
logger.info(
|
||||
f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, "
|
||||
f"user_uuid: {db_token.user_uuid}"
|
||||
)
|
||||
if db_token.expires_at < datetime.now(timezone.utc):
|
||||
raise TokenExpiredError()
|
||||
|
||||
# 5. 사용자 확인
|
||||
user_uuid = payload.get("sub")
|
||||
user = await self._get_user_by_uuid(user_uuid, session)
|
||||
# 4. 사용자 확인
|
||||
user_id = int(payload.get("sub"))
|
||||
user = await self._get_user_by_id(user_id, session)
|
||||
|
||||
if user is None:
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}"
|
||||
)
|
||||
raise UserNotFoundError()
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, "
|
||||
f"user_id: {user.id}"
|
||||
)
|
||||
raise UserInactiveError()
|
||||
|
||||
# 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음)
|
||||
db_token.is_revoked = True
|
||||
db_token.revoked_at = now().replace(tzinfo=None)
|
||||
logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...")
|
||||
# 5. 새 액세스 토큰 발급
|
||||
new_access_token = create_access_token(user.id)
|
||||
|
||||
# 7. 새 토큰 발급
|
||||
new_access_token = create_access_token(user.user_uuid)
|
||||
new_refresh_token = create_refresh_token(user.user_uuid)
|
||||
logger.debug(
|
||||
f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, "
|
||||
f"new_refresh: ...{new_refresh_token[-20:]}"
|
||||
)
|
||||
|
||||
# 8. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행)
|
||||
await self._save_refresh_token(
|
||||
user_id=user.id,
|
||||
user_uuid=user.user_uuid,
|
||||
token=new_refresh_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 폐기 + 저장을 하나의 트랜잭션으로 커밋
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, "
|
||||
f"user_id: {user.id}, old_hash: {token_hash[:16]}..., "
|
||||
f"new_refresh: ...{new_refresh_token[-20:]}"
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
return AccessTokenResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="Bearer",
|
||||
expires_in=get_access_token_expire_seconds(),
|
||||
)
|
||||
|
|
@ -330,12 +209,7 @@ class AuthService:
|
|||
session: DB 세션
|
||||
"""
|
||||
token_hash = get_token_hash(refresh_token)
|
||||
logger.info(
|
||||
f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., "
|
||||
f"token: ...{refresh_token[-20:]}"
|
||||
)
|
||||
await self._revoke_refresh_token_by_hash(token_hash, session)
|
||||
logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}")
|
||||
|
||||
async def logout_all(
|
||||
self,
|
||||
|
|
@ -349,9 +223,7 @@ class AuthService:
|
|||
user_id: 사용자 ID
|
||||
session: DB 세션
|
||||
"""
|
||||
logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}")
|
||||
await self._revoke_all_user_tokens(user_id, session)
|
||||
logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}")
|
||||
|
||||
async def _get_or_create_user(
|
||||
self,
|
||||
|
|
@ -396,59 +268,22 @@ 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,
|
||||
thumbnail_image_url=profile.thumbnail_image_url if profile else None,
|
||||
)
|
||||
session.add(new_user)
|
||||
|
||||
try:
|
||||
await session.flush()
|
||||
await session.refresh(new_user)
|
||||
logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
|
||||
return new_user, True
|
||||
except IntegrityError:
|
||||
# 동시 요청으로 인한 중복 삽입 시도 - 기존 사용자 조회
|
||||
logger.warning(
|
||||
f"[AUTH] IntegrityError 발생 (동시 요청 추정) - kakao_id: {kakao_id}, "
|
||||
"기존 사용자 재조회 시도"
|
||||
)
|
||||
await session.rollback()
|
||||
result = await session.execute(
|
||||
select(User).where(User.kakao_id == kakao_id)
|
||||
)
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user is not None:
|
||||
logger.info(
|
||||
f"[AUTH] 기존 사용자 재조회 성공 - user_id: {existing_user.id}, "
|
||||
"is_new_user: False"
|
||||
)
|
||||
# 프로필 정보 업데이트
|
||||
if profile:
|
||||
existing_user.nickname = profile.nickname
|
||||
existing_user.profile_image_url = profile.profile_image_url
|
||||
existing_user.thumbnail_image_url = profile.thumbnail_image_url
|
||||
if kakao_account and kakao_account.email:
|
||||
existing_user.email = kakao_account.email
|
||||
await session.flush()
|
||||
return existing_user, False
|
||||
|
||||
# 재조회에도 실패한 경우 (매우 드문 경우)
|
||||
logger.error(
|
||||
f"[AUTH] IntegrityError 후 재조회 실패 - kakao_id: {kakao_id}"
|
||||
)
|
||||
raise
|
||||
await session.flush()
|
||||
await session.refresh(new_user)
|
||||
logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
|
||||
return new_user, True
|
||||
|
||||
async def _save_refresh_token(
|
||||
self,
|
||||
user_id: int,
|
||||
user_uuid: str,
|
||||
token: str,
|
||||
session: AsyncSession,
|
||||
user_agent: Optional[str] = None,
|
||||
|
|
@ -459,7 +294,6 @@ class AuthService:
|
|||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
user_uuid: 사용자 UUID
|
||||
token: 리프레시 토큰
|
||||
session: DB 세션
|
||||
user_agent: User-Agent
|
||||
|
|
@ -473,7 +307,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,
|
||||
|
|
@ -481,11 +314,6 @@ class AuthService:
|
|||
)
|
||||
session.add(refresh_token)
|
||||
await session.flush()
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, "
|
||||
f"token_hash: {token_hash[:16]}..., expires_at: {expires_at}"
|
||||
)
|
||||
return refresh_token
|
||||
|
||||
async def _get_refresh_token_by_hash(
|
||||
|
|
@ -528,26 +356,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,
|
||||
|
|
@ -565,7 +373,7 @@ class AuthService:
|
|||
.where(RefreshToken.token_hash == token_hash)
|
||||
.values(
|
||||
is_revoked=True,
|
||||
revoked_at=now().replace(tzinfo=None),
|
||||
revoked_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
|
@ -590,7 +398,7 @@ class AuthService:
|
|||
)
|
||||
.values(
|
||||
is_revoked=True,
|
||||
revoked_at=now().replace(tzinfo=None),
|
||||
revoked_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
|
|
|||
|
|
@ -5,77 +5,62 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니
|
|||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
|
||||
|
||||
from app.utils.timezone import now
|
||||
from config import jwt_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 액세스 토큰 문자열
|
||||
"""
|
||||
expire = now() + timedelta(
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode = {
|
||||
"sub": user_uuid,
|
||||
"sub": str(user_id),
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
token = jwt.encode(
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
jwt_settings.JWT_SECRET,
|
||||
algorithm=jwt_settings.JWT_ALGORITHM,
|
||||
)
|
||||
logger.debug(
|
||||
f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, "
|
||||
f"expires: {expire}, token: ...{token[-20:]}"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
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 리프레시 토큰 문자열
|
||||
"""
|
||||
expire = now() + timedelta(
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
to_encode = {
|
||||
"sub": user_uuid,
|
||||
"sub": str(user_id),
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
token = jwt.encode(
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
jwt_settings.JWT_SECRET,
|
||||
algorithm=jwt_settings.JWT_ALGORITHM,
|
||||
)
|
||||
logger.debug(
|
||||
f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, "
|
||||
f"expires: {expire}, token: ...{token[-20:]}"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
|
|
@ -94,25 +79,8 @@ def decode_token(token: str) -> Optional[dict]:
|
|||
jwt_settings.JWT_SECRET,
|
||||
algorithms=[jwt_settings.JWT_ALGORITHM],
|
||||
)
|
||||
logger.debug(
|
||||
f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, "
|
||||
f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, "
|
||||
f"token: ...{token[-20:]}"
|
||||
)
|
||||
return payload
|
||||
except ExpiredSignatureError:
|
||||
logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}")
|
||||
return None
|
||||
except JWTClaimsError as e:
|
||||
logger.warning(
|
||||
f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}"
|
||||
)
|
||||
return None
|
||||
except JWTError as e:
|
||||
logger.warning(
|
||||
f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, "
|
||||
f"token: ...{token[-20:]}"
|
||||
)
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -136,9 +104,9 @@ def get_refresh_token_expires_at() -> datetime:
|
|||
리프레시 토큰 만료 시간 계산
|
||||
|
||||
Returns:
|
||||
리프레시 토큰 만료 datetime (로컬 시간)
|
||||
리프레시 토큰 만료 datetime (UTC)
|
||||
"""
|
||||
return now().replace(tzinfo=None) + timedelta(
|
||||
return datetime.now(timezone.utc) + timedelta(
|
||||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,39 +7,15 @@
|
|||
import logging
|
||||
|
||||
import aiohttp
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from config import kakao_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError
|
||||
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 카카오 OAuth 예외 클래스 정의
|
||||
# =============================================================================
|
||||
class KakaoException(HTTPException):
|
||||
"""카카오 관련 기본 예외"""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str):
|
||||
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
class KakaoAuthFailedError(KakaoException):
|
||||
"""카카오 인증 실패"""
|
||||
|
||||
def __init__(self, message: str = "카카오 인증에 실패했습니다."):
|
||||
super().__init__(status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED", message)
|
||||
|
||||
|
||||
class KakaoAPIError(KakaoException):
|
||||
"""카카오 API 호출 오류"""
|
||||
|
||||
def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."):
|
||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "KAKAO_API_ERROR", message)
|
||||
|
||||
|
||||
class KakaoOAuthClient:
|
||||
"""
|
||||
카카오 OAuth API 클라이언트
|
||||
|
|
|
|||
|
|
@ -1,259 +0,0 @@
|
|||
"""
|
||||
SocialAccount 서비스 레이어
|
||||
|
||||
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.user.models import Platform, SocialAccount, User
|
||||
from app.user.schemas.social_account_schema import (
|
||||
SocialAccountCreateRequest,
|
||||
SocialAccountUpdateRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SocialAccountService:
|
||||
"""소셜 계정 서비스"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_list(self, user: User) -> list[SocialAccount]:
|
||||
"""
|
||||
사용자의 소셜 계정 목록 조회
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
|
||||
Returns:
|
||||
list[SocialAccount]: 소셜 계정 목록
|
||||
"""
|
||||
logger.debug(f"[SocialAccountService.get_list] START - user_uuid: {user.user_uuid}")
|
||||
|
||||
result = await self.session.execute(
|
||||
select(SocialAccount).where(
|
||||
and_(
|
||||
SocialAccount.user_uuid == user.user_uuid,
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
).order_by(SocialAccount.created_at.desc())
|
||||
)
|
||||
accounts = list(result.scalars().all())
|
||||
|
||||
logger.debug(f"[SocialAccountService.get_list] SUCCESS - count: {len(accounts)}")
|
||||
return accounts
|
||||
|
||||
async def get_by_id(self, user: User, account_id: int) -> Optional[SocialAccount]:
|
||||
"""
|
||||
ID로 소셜 계정 조회
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
account_id: 소셜 계정 ID
|
||||
|
||||
Returns:
|
||||
SocialAccount | None: 소셜 계정 또는 None
|
||||
"""
|
||||
logger.debug(f"[SocialAccountService.get_by_id] START - user_uuid: {user.user_uuid}, account_id: {account_id}")
|
||||
|
||||
result = await self.session.execute(
|
||||
select(SocialAccount).where(
|
||||
and_(
|
||||
SocialAccount.id == account_id,
|
||||
SocialAccount.user_uuid == user.user_uuid,
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if account:
|
||||
logger.debug(f"[SocialAccountService.get_by_id] SUCCESS - platform: {account.platform}")
|
||||
else:
|
||||
logger.debug(f"[SocialAccountService.get_by_id] NOT_FOUND - account_id: {account_id}")
|
||||
|
||||
return account
|
||||
|
||||
async def get_by_platform(
|
||||
self,
|
||||
user: User,
|
||||
platform: Platform,
|
||||
platform_user_id: Optional[str] = None,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
플랫폼별 소셜 계정 조회
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
platform: 플랫폼
|
||||
platform_user_id: 플랫폼 사용자 ID (선택)
|
||||
|
||||
Returns:
|
||||
SocialAccount | None: 소셜 계정 또는 None
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SocialAccountService.get_by_platform] START - user_uuid: {user.user_uuid}, "
|
||||
f"platform: {platform}, platform_user_id: {platform_user_id}"
|
||||
)
|
||||
|
||||
conditions = [
|
||||
SocialAccount.user_uuid == user.user_uuid,
|
||||
SocialAccount.platform == platform,
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
]
|
||||
|
||||
if platform_user_id:
|
||||
conditions.append(SocialAccount.platform_user_id == platform_user_id)
|
||||
|
||||
result = await self.session.execute(
|
||||
select(SocialAccount).where(and_(*conditions))
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if account:
|
||||
logger.debug(f"[SocialAccountService.get_by_platform] SUCCESS - id: {account.id}")
|
||||
else:
|
||||
logger.debug(f"[SocialAccountService.get_by_platform] NOT_FOUND")
|
||||
|
||||
return account
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user: User,
|
||||
data: SocialAccountCreateRequest,
|
||||
) -> SocialAccount:
|
||||
"""
|
||||
소셜 계정 생성
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
data: 생성 요청 데이터
|
||||
|
||||
Returns:
|
||||
SocialAccount: 생성된 소셜 계정
|
||||
|
||||
Raises:
|
||||
ValueError: 이미 연동된 계정이 존재하는 경우
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SocialAccountService.create] START - user_uuid: {user.user_uuid}, "
|
||||
f"platform: {data.platform}, platform_user_id: {data.platform_user_id}"
|
||||
)
|
||||
|
||||
# 중복 확인
|
||||
existing = await self.get_by_platform(user, data.platform, data.platform_user_id)
|
||||
if existing:
|
||||
logger.warning(
|
||||
f"[SocialAccountService.create] DUPLICATE - "
|
||||
f"platform: {data.platform}, platform_user_id: {data.platform_user_id}"
|
||||
)
|
||||
raise ValueError(f"이미 연동된 {data.platform.value} 계정입니다.")
|
||||
|
||||
account = SocialAccount(
|
||||
user_uuid=user.user_uuid,
|
||||
platform=data.platform,
|
||||
access_token=data.access_token,
|
||||
refresh_token=data.refresh_token,
|
||||
token_expires_at=data.token_expires_at,
|
||||
scope=data.scope,
|
||||
platform_user_id=data.platform_user_id,
|
||||
platform_username=data.platform_username,
|
||||
platform_data=data.platform_data,
|
||||
is_active=True,
|
||||
is_deleted=False,
|
||||
)
|
||||
|
||||
self.session.add(account)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(account)
|
||||
|
||||
logger.info(
|
||||
f"[SocialAccountService.create] SUCCESS - id: {account.id}, "
|
||||
f"platform: {account.platform}, platform_username: {account.platform_username}"
|
||||
)
|
||||
return account
|
||||
|
||||
async def update(
|
||||
self,
|
||||
user: User,
|
||||
account_id: int,
|
||||
data: SocialAccountUpdateRequest,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
소셜 계정 수정
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
account_id: 소셜 계정 ID
|
||||
data: 수정 요청 데이터
|
||||
|
||||
Returns:
|
||||
SocialAccount | None: 수정된 소셜 계정 또는 None
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SocialAccountService.update] START - user_uuid: {user.user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
|
||||
account = await self.get_by_id(user, account_id)
|
||||
if not account:
|
||||
logger.warning(f"[SocialAccountService.update] NOT_FOUND - account_id: {account_id}")
|
||||
return None
|
||||
|
||||
# 변경된 필드만 업데이트
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(account, field, value)
|
||||
|
||||
await self.session.commit()
|
||||
await self.session.refresh(account)
|
||||
|
||||
logger.info(
|
||||
f"[SocialAccountService.update] SUCCESS - id: {account.id}, "
|
||||
f"updated_fields: {list(update_data.keys())}"
|
||||
)
|
||||
return account
|
||||
|
||||
async def delete(self, user: User, account_id: int) -> Optional[int]:
|
||||
"""
|
||||
소셜 계정 소프트 삭제
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
account_id: 소셜 계정 ID
|
||||
|
||||
Returns:
|
||||
int | None: 삭제된 계정 ID 또는 None
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SocialAccountService.delete] START - user_uuid: {user.user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
|
||||
account = await self.get_by_id(user, account_id)
|
||||
if not account:
|
||||
logger.warning(f"[SocialAccountService.delete] NOT_FOUND - account_id: {account_id}")
|
||||
return None
|
||||
|
||||
account.is_deleted = True
|
||||
account.is_active = False
|
||||
await self.session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[SocialAccountService.delete] SUCCESS - id: {account_id}, platform: {account.platform}"
|
||||
)
|
||||
return account_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 의존성 주입용 함수
|
||||
# =============================================================================
|
||||
async def get_social_account_service(session: AsyncSession) -> SocialAccountService:
|
||||
"""SocialAccountService 인스턴스 반환"""
|
||||
return SocialAccountService(session)
|
||||
|
|
@ -1,94 +1,47 @@
|
|||
import json
|
||||
import re
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import apikey_settings, recovery_settings
|
||||
from config import apikey_settings
|
||||
from app.utils.prompts.prompts import Prompt
|
||||
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("chatgpt")
|
||||
|
||||
|
||||
class ChatGPTResponseError(Exception):
|
||||
"""ChatGPT API 응답 에러"""
|
||||
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
||||
self.status = status
|
||||
self.error_code = error_code
|
||||
self.error_message = error_message
|
||||
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
||||
# fmt: on
|
||||
|
||||
|
||||
class ChatgptService:
|
||||
"""ChatGPT API 서비스 클래스
|
||||
|
||||
GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다.
|
||||
"""
|
||||
|
||||
def __init__(self, timeout: float = None):
|
||||
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
||||
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=apikey_settings.CHATGPT_API_KEY,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||
|
||||
async def _call_structured_output_with_response_gpt_api(self, prompt: str, output_format : dict, model:str) -> dict:
|
||||
content = [{"type": "input_text", "text": prompt}]
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
response = await self.client.responses.parse(
|
||||
model=model,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text_format=output_format
|
||||
)
|
||||
# Response 디버그 로깅
|
||||
logger.debug(f"[ChatgptService] Response ID: {response.id}")
|
||||
logger.debug(f"[ChatgptService] Response status: {response.status}")
|
||||
logger.debug(f"[ChatgptService] Response model: {response.model}")
|
||||
response = await self.client.responses.create(
|
||||
model=model,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text = output_format
|
||||
)
|
||||
structured_output = json.loads(response.output_text)
|
||||
return structured_output or {}
|
||||
|
||||
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
||||
if response.status == "completed":
|
||||
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
||||
structured_output = response.output_parsed
|
||||
return structured_output #.model_dump() or {}
|
||||
|
||||
# 에러 상태 처리
|
||||
if response.status == "failed":
|
||||
error_code = getattr(response.error, 'code', None) if response.error else None
|
||||
error_message = getattr(response.error, 'message', None) if response.error else None
|
||||
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
||||
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
||||
|
||||
elif response.status == "incomplete":
|
||||
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
||||
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
||||
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
||||
|
||||
else:
|
||||
# cancelled, queued, in_progress 등 예상치 못한 상태
|
||||
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
||||
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < self.max_retries:
|
||||
logger.info(f"[ChatgptService] Retrying request...")
|
||||
|
||||
# 모든 재시도 실패
|
||||
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
|
||||
raise last_error
|
||||
|
||||
async def generate_structured_output(
|
||||
self,
|
||||
prompt : Prompt,
|
||||
input_data : dict,
|
||||
) -> BaseModel:
|
||||
) -> str:
|
||||
prompt_text = prompt.build_prompt(input_data)
|
||||
|
||||
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
||||
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||
|
||||
# GPT API 호출
|
||||
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
|
||||
response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -36,29 +36,12 @@ from typing import Literal
|
|||
import httpx
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import apikey_settings, creatomate_settings, recovery_settings
|
||||
from config import apikey_settings, creatomate_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("creatomate")
|
||||
|
||||
|
||||
class CreatomateResponseError(Exception):
|
||||
"""Creatomate API 응답 오류 시 발생하는 예외
|
||||
|
||||
Creatomate API 렌더링 실패 또는 비정상 응답 시 사용됩니다.
|
||||
재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다.
|
||||
|
||||
Attributes:
|
||||
message: 에러 메시지
|
||||
original_response: 원본 API 응답 (있는 경우)
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, original_response: dict | None = None):
|
||||
self.message = message
|
||||
self.original_response = original_response
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
# Orientation 타입 정의
|
||||
OrientationType = Literal["horizontal", "vertical"]
|
||||
|
||||
|
|
@ -122,38 +105,7 @@ text_template_v_2 = {
|
|||
}
|
||||
]
|
||||
}
|
||||
text_template_v_3 = {
|
||||
"type": "composition",
|
||||
"track": 3,
|
||||
"elements": [
|
||||
{
|
||||
"type": "text",
|
||||
"time": 0,
|
||||
"x": "0%",
|
||||
"y": "80%",
|
||||
"width": "100%",
|
||||
"height": "15%",
|
||||
"x_anchor": "0%",
|
||||
"y_anchor": "0%",
|
||||
"x_alignment": "50%",
|
||||
"y_alignment": "50%",
|
||||
"font_family": "Noto Sans",
|
||||
"font_weight": "700",
|
||||
"font_size_maximum": "7 vmin",
|
||||
"fill_color": "#ffffff",
|
||||
"animations": [
|
||||
{
|
||||
"time": 0,
|
||||
"duration": 1,
|
||||
"easing": "quadratic-out",
|
||||
"type": "text-wave",
|
||||
"split": "line",
|
||||
"overlap": "50%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
text_template_h_1 = {
|
||||
"type": "composition",
|
||||
"track": 3,
|
||||
|
|
@ -178,58 +130,12 @@ text_template_h_1 = {
|
|||
]
|
||||
}
|
||||
|
||||
autotext_template_v_1 = {
|
||||
"type": "text",
|
||||
"track": 4,
|
||||
"time": 0,
|
||||
"y": "87.9086%",
|
||||
"width": "100%",
|
||||
"height": "40%",
|
||||
"x_alignment": "50%",
|
||||
"y_alignment": "50%",
|
||||
"font_family": "Noto Sans",
|
||||
"font_weight": "700",
|
||||
"font_size": "8 vmin",
|
||||
"background_color": "rgba(216,216,216,0)",
|
||||
"background_x_padding": "33%",
|
||||
"background_y_padding": "7%",
|
||||
"background_border_radius": "28%",
|
||||
"transcript_source": "audio-music", # audio-music과 연동됨
|
||||
"transcript_effect": "karaoke",
|
||||
"fill_color": "#ffffff",
|
||||
"stroke_color": "rgba(51,51,51,1)",
|
||||
"stroke_width": "0.6 vmin"
|
||||
}
|
||||
autotext_template_h_1 = {
|
||||
"type": "text",
|
||||
"track": 4,
|
||||
"time": 0,
|
||||
"x": "10%",
|
||||
"y": "83.2953%",
|
||||
"width": "80%",
|
||||
"height": "15%",
|
||||
"x_anchor": "0%",
|
||||
"y_anchor": "0%",
|
||||
"x_alignment": "50%",
|
||||
"font_family": "Noto Sans",
|
||||
"font_weight": "700",
|
||||
"font_size": "5.9998 vmin",
|
||||
"transcript_source": "audio-music",
|
||||
"transcript_effect": "karaoke",
|
||||
"fill_color": "#ffffff",
|
||||
"stroke_color": "#333333",
|
||||
"stroke_width": "0.2 vmin"
|
||||
}
|
||||
|
||||
async def get_shared_client() -> httpx.AsyncClient:
|
||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||
global _shared_client
|
||||
if _shared_client is None or _shared_client.is_closed:
|
||||
_shared_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||
connect=recovery_settings.CREATOMATE_CONNECT_TIMEOUT,
|
||||
),
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||
)
|
||||
return _shared_client
|
||||
|
|
@ -311,7 +217,7 @@ class CreatomateService:
|
|||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
timeout: float | None = None,
|
||||
timeout: float = 30.0,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""HTTP 요청을 수행합니다.
|
||||
|
|
@ -319,7 +225,7 @@ class CreatomateService:
|
|||
Args:
|
||||
method: HTTP 메서드 ("GET", "POST", etc.)
|
||||
url: 요청 URL
|
||||
timeout: 요청 타임아웃 (초). None이면 기본값 사용
|
||||
timeout: 요청 타임아웃 (초)
|
||||
**kwargs: httpx 요청에 전달할 추가 인자
|
||||
|
||||
Returns:
|
||||
|
|
@ -330,18 +236,15 @@ class CreatomateService:
|
|||
"""
|
||||
logger.info(f"[Creatomate] {method} {url}")
|
||||
|
||||
# timeout이 None이면 기본 타임아웃 사용
|
||||
actual_timeout = timeout if timeout is not None else recovery_settings.CREATOMATE_DEFAULT_TIMEOUT
|
||||
|
||||
client = await get_shared_client()
|
||||
|
||||
if method.upper() == "GET":
|
||||
response = await client.get(
|
||||
url, headers=self.headers, timeout=actual_timeout, **kwargs
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
)
|
||||
elif method.upper() == "POST":
|
||||
response = await client.post(
|
||||
url, headers=self.headers, timeout=actual_timeout, **kwargs
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
|
@ -352,7 +255,7 @@ class CreatomateService:
|
|||
async def get_all_templates_data(self) -> dict:
|
||||
"""모든 템플릿 정보를 조회합니다."""
|
||||
url = f"{self.BASE_URL}/v1/templates"
|
||||
response = await self._request("GET", url) # 기본 타임아웃 사용
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
@ -385,7 +288,7 @@ class CreatomateService:
|
|||
|
||||
# API 호출
|
||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||
response = await self._request("GET", url) # 기본 타임아웃 사용
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
|
@ -439,7 +342,6 @@ class CreatomateService:
|
|||
image_url_list: list[str],
|
||||
lyric: str,
|
||||
music_url: str,
|
||||
address: str = None
|
||||
) -> dict:
|
||||
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
||||
|
||||
|
|
@ -466,8 +368,9 @@ class CreatomateService:
|
|||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
if "address_input" in template_component_name:
|
||||
modifications[template_component_name] = address
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
|
|
@ -479,11 +382,12 @@ class CreatomateService:
|
|||
image_url_list: list[str],
|
||||
lyric: str,
|
||||
music_url: str,
|
||||
address: str = None
|
||||
) -> dict:
|
||||
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
|
||||
template_component_data = self.parse_template_component_name(elements)
|
||||
|
||||
lyric = lyric.replace("\r", "")
|
||||
lyric_splited = lyric.split("\n")
|
||||
modifications = {}
|
||||
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
|
|
@ -495,8 +399,9 @@ class CreatomateService:
|
|||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
if "address_input" in template_component_name:
|
||||
modifications[template_component_name] = address
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
|
|
@ -515,8 +420,7 @@ class CreatomateService:
|
|||
case "video":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "text":
|
||||
#element["source"] = modification[element["name"]]
|
||||
element["text"] = modification.get(element["name"], "")
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "composition":
|
||||
for minor in element["elements"]:
|
||||
recursive_modify(minor)
|
||||
|
|
@ -529,149 +433,30 @@ class CreatomateService:
|
|||
async def make_creatomate_call(
|
||||
self, template_id: str, modifications: dict
|
||||
) -> dict:
|
||||
"""Creatomate에 렌더링 요청을 보냅니다 (재시도 로직 포함).
|
||||
|
||||
Args:
|
||||
template_id: Creatomate 템플릿 ID
|
||||
modifications: 수정사항 딕셔너리
|
||||
|
||||
Returns:
|
||||
Creatomate API 응답 데이터
|
||||
|
||||
Raises:
|
||||
CreatomateResponseError: API 오류 또는 재시도 실패 시
|
||||
"""Creatomate에 렌더링 요청을 보냅니다.
|
||||
|
||||
Note:
|
||||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
payload = {
|
||||
data = {
|
||||
"template_id": template_id,
|
||||
"modifications": modifications,
|
||||
}
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
||||
try:
|
||||
response = await self._request(
|
||||
"POST",
|
||||
url,
|
||||
timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
# 200 OK, 201 Created, 202 Accepted 모두 성공으로 처리
|
||||
if response.status_code in (200, 201, 202):
|
||||
return response.json()
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
if 400 <= response.status_code < 500:
|
||||
raise CreatomateResponseError(
|
||||
f"Client error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
# 재시도 가능한 오류 (5xx 서버 오류)
|
||||
last_error = CreatomateResponseError(
|
||||
f"Server error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning(
|
||||
f"[Creatomate] Timeout on attempt {attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES + 1}"
|
||||
)
|
||||
last_error = e
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"[Creatomate] HTTP error on attempt {attempt + 1}: {e}")
|
||||
last_error = e
|
||||
|
||||
except CreatomateResponseError:
|
||||
raise # CreatomateResponseError는 재시도하지 않고 즉시 전파
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < recovery_settings.CREATOMATE_MAX_RETRIES:
|
||||
logger.info(
|
||||
f"[Creatomate] Retrying... ({attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES})"
|
||||
)
|
||||
|
||||
# 모든 재시도 실패
|
||||
raise CreatomateResponseError(
|
||||
f"All {recovery_settings.CREATOMATE_MAX_RETRIES + 1} attempts failed",
|
||||
original_response={"last_error": str(last_error)},
|
||||
)
|
||||
response = await self._request("POST", url, timeout=60.0, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def make_creatomate_custom_call(self, source: dict) -> dict:
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다 (재시도 로직 포함).
|
||||
|
||||
Args:
|
||||
source: 렌더링 소스 딕셔너리
|
||||
|
||||
Returns:
|
||||
Creatomate API 응답 데이터
|
||||
|
||||
Raises:
|
||||
CreatomateResponseError: API 오류 또는 재시도 실패 시
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||
|
||||
Note:
|
||||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
||||
try:
|
||||
response = await self._request(
|
||||
"POST",
|
||||
url,
|
||||
timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||
json=source,
|
||||
)
|
||||
|
||||
# 200 OK, 201 Created, 202 Accepted 모두 성공으로 처리
|
||||
if response.status_code in (200, 201, 202):
|
||||
return response.json()
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
if 400 <= response.status_code < 500:
|
||||
raise CreatomateResponseError(
|
||||
f"Client error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
# 재시도 가능한 오류 (5xx 서버 오류)
|
||||
last_error = CreatomateResponseError(
|
||||
f"Server error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning(
|
||||
f"[Creatomate] Timeout on attempt {attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES + 1}"
|
||||
)
|
||||
last_error = e
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"[Creatomate] HTTP error on attempt {attempt + 1}: {e}")
|
||||
last_error = e
|
||||
|
||||
except CreatomateResponseError:
|
||||
raise # CreatomateResponseError는 재시도하지 않고 즉시 전파
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < recovery_settings.CREATOMATE_MAX_RETRIES:
|
||||
logger.info(
|
||||
f"[Creatomate] Retrying... ({attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES})"
|
||||
)
|
||||
|
||||
# 모든 재시도 실패
|
||||
raise CreatomateResponseError(
|
||||
f"All {recovery_settings.CREATOMATE_MAX_RETRIES + 1} attempts failed",
|
||||
original_response={"last_error": str(last_error)},
|
||||
)
|
||||
response = await self._request("POST", url, timeout=60.0, json=source)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
||||
|
|
@ -700,7 +485,7 @@ class CreatomateService:
|
|||
- failed: 실패
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
||||
response = await self._request("GET", url) # 기본 타임아웃 사용
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
@ -734,8 +519,8 @@ class CreatomateService:
|
|||
|
||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||
template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
||||
target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
||||
target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
||||
template["duration"] = target_duration
|
||||
total_template_duration = self.calc_scene_duration(template)
|
||||
extend_rate = target_duration / total_template_duration
|
||||
new_template = copy.deepcopy(template)
|
||||
|
|
@ -757,7 +542,7 @@ class CreatomateService:
|
|||
|
||||
return new_template
|
||||
|
||||
def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float, font_family: str = "Noto Sans") -> dict:
|
||||
def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float) -> dict:
|
||||
duration = end_sec - start_sec
|
||||
text_scene = copy.deepcopy(text_template)
|
||||
text_scene["name"] = f"Caption-{lyric_index}"
|
||||
|
|
@ -765,24 +550,13 @@ class CreatomateService:
|
|||
text_scene["time"] = start_sec
|
||||
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
|
||||
text_scene["elements"][0]["text"] = lyric_text
|
||||
text_scene["elements"][0]["font_family"] = font_family
|
||||
return text_scene
|
||||
|
||||
def auto_lyric(self, auto_text_template : dict):
|
||||
text_scene = copy.deepcopy(auto_text_template)
|
||||
return text_scene
|
||||
|
||||
|
||||
def get_text_template(self):
|
||||
match self.orientation:
|
||||
case "vertical":
|
||||
return text_template_v_3
|
||||
return text_template_v_2
|
||||
case "horizontal":
|
||||
return text_template_h_1
|
||||
|
||||
def get_auto_text_template(self):
|
||||
match self.orientation:
|
||||
case "vertical":
|
||||
return autotext_template_v_1
|
||||
case "horizontal":
|
||||
return autotext_template_h_1
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,398 +0,0 @@
|
|||
"""
|
||||
Instagram Graph API Client
|
||||
|
||||
Instagram Graph API를 사용한 비디오/릴스 게시를 위한 비동기 클라이언트입니다.
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with InstagramClient(access_token="YOUR_TOKEN") as client:
|
||||
media = await client.publish_video(
|
||||
video_url="https://example.com/video.mp4",
|
||||
caption="Hello Instagram!"
|
||||
)
|
||||
print(f"게시 완료: {media.permalink}")
|
||||
```
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.sns.schemas.sns_schema import ErrorResponse, Media, MediaContainer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Error State & Parser
|
||||
# ============================================================
|
||||
|
||||
|
||||
class ErrorState(str, Enum):
|
||||
"""Instagram API 에러 상태"""
|
||||
|
||||
RATE_LIMIT = "rate_limit"
|
||||
AUTH_ERROR = "auth_error"
|
||||
CONTAINER_TIMEOUT = "container_timeout"
|
||||
CONTAINER_ERROR = "container_error"
|
||||
API_ERROR = "api_error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def parse_instagram_error(e: Exception) -> tuple[ErrorState, str, dict]:
|
||||
"""
|
||||
Instagram 예외를 파싱하여 상태, 메시지, 추가 정보를 반환
|
||||
|
||||
Args:
|
||||
e: 발생한 예외
|
||||
|
||||
Returns:
|
||||
tuple: (error_state, message, extra_info)
|
||||
|
||||
Example:
|
||||
>>> error_state, message, extra_info = parse_instagram_error(e)
|
||||
>>> if error_state == ErrorState.RATE_LIMIT:
|
||||
... retry_after = extra_info.get("retry_after", 60)
|
||||
"""
|
||||
error_str = str(e)
|
||||
extra_info = {}
|
||||
|
||||
# Rate Limit 에러
|
||||
if "[RateLimit]" in error_str:
|
||||
match = re.search(r"retry_after=(\d+)s", error_str)
|
||||
if match:
|
||||
extra_info["retry_after"] = int(match.group(1))
|
||||
return ErrorState.RATE_LIMIT, "API 호출 제한 초과", extra_info
|
||||
|
||||
# 인증 에러 (code=190)
|
||||
if "code=190" in error_str:
|
||||
return ErrorState.AUTH_ERROR, "인증 실패 (토큰 만료 또는 무효)", extra_info
|
||||
|
||||
# 컨테이너 타임아웃
|
||||
if "[ContainerTimeout]" in error_str:
|
||||
match = re.search(r"\((\d+)초 초과\)", error_str)
|
||||
if match:
|
||||
extra_info["timeout"] = int(match.group(1))
|
||||
return ErrorState.CONTAINER_TIMEOUT, "미디어 처리 시간 초과", extra_info
|
||||
|
||||
# 컨테이너 상태 에러
|
||||
if "[ContainerStatus]" in error_str:
|
||||
match = re.search(r"처리 실패: (\w+)", error_str)
|
||||
if match:
|
||||
extra_info["status"] = match.group(1)
|
||||
return ErrorState.CONTAINER_ERROR, "미디어 컨테이너 처리 실패", extra_info
|
||||
|
||||
# Instagram API 에러
|
||||
if "[InstagramAPI]" in error_str:
|
||||
match = re.search(r"code=(\d+)", error_str)
|
||||
if match:
|
||||
extra_info["code"] = int(match.group(1))
|
||||
return ErrorState.API_ERROR, "Instagram API 오류", extra_info
|
||||
|
||||
return ErrorState.UNKNOWN, str(e), extra_info
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Instagram Client
|
||||
# ============================================================
|
||||
|
||||
|
||||
class InstagramClient:
|
||||
"""
|
||||
Instagram Graph API 비동기 클라이언트 (비디오 업로드 전용)
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with InstagramClient(access_token="USER_TOKEN") as client:
|
||||
media = await client.publish_video(
|
||||
video_url="https://example.com/video.mp4",
|
||||
caption="My video!"
|
||||
)
|
||||
print(f"게시됨: {media.permalink}")
|
||||
```
|
||||
"""
|
||||
|
||||
DEFAULT_BASE_URL = "https://graph.instagram.com/v21.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
container_timeout: float = 300.0,
|
||||
container_poll_interval: float = 5.0,
|
||||
):
|
||||
"""
|
||||
클라이언트 초기화
|
||||
|
||||
Args:
|
||||
access_token: Instagram 액세스 토큰 (필수)
|
||||
base_url: API 기본 URL (기본값: https://graph.instagram.com/v21.0)
|
||||
timeout: HTTP 요청 타임아웃 (초)
|
||||
max_retries: 최대 재시도 횟수
|
||||
container_timeout: 컨테이너 처리 대기 타임아웃 (초)
|
||||
container_poll_interval: 컨테이너 상태 확인 간격 (초)
|
||||
"""
|
||||
if not access_token:
|
||||
raise ValueError("access_token은 필수입니다.")
|
||||
|
||||
self.access_token = access_token
|
||||
self.base_url = base_url or self.DEFAULT_BASE_URL
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.container_timeout = container_timeout
|
||||
self.container_poll_interval = container_poll_interval
|
||||
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._account_id: Optional[str] = None
|
||||
self._account_id_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
async def __aenter__(self) -> "InstagramClient":
|
||||
"""비동기 컨텍스트 매니저 진입"""
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
follow_redirects=True,
|
||||
)
|
||||
logger.debug("[InstagramClient] HTTP 클라이언트 초기화 완료")
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""비동기 컨텍스트 매니저 종료"""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.debug("[InstagramClient] HTTP 클라이언트 종료")
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
"""HTTP 클라이언트 반환"""
|
||||
if self._client is None:
|
||||
raise RuntimeError(
|
||||
"InstagramClient는 비동기 컨텍스트 매니저로 사용해야 합니다. "
|
||||
"예: async with InstagramClient(access_token=...) as client:"
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _build_url(self, endpoint: str) -> str:
|
||||
"""API URL 생성"""
|
||||
return f"{self.base_url}/{endpoint}"
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
data: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
공통 HTTP 요청 처리
|
||||
|
||||
- Rate Limit 시 지수 백오프 재시도
|
||||
- 에러 응답 시 InstagramAPIError 발생
|
||||
"""
|
||||
client = self._get_client()
|
||||
url = self._build_url(endpoint)
|
||||
params = params or {}
|
||||
params["access_token"] = self.access_token
|
||||
|
||||
retry_base_delay = 1.0
|
||||
last_exception: Optional[Exception] = None
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
logger.debug(
|
||||
f"[API] {method} {endpoint} (attempt {attempt + 1}/{self.max_retries + 1})"
|
||||
)
|
||||
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
params=params,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Rate Limit 체크 (429)
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get("Retry-After", 60))
|
||||
if attempt < self.max_retries:
|
||||
wait_time = max(retry_base_delay * (2**attempt), retry_after)
|
||||
logger.warning(f"Rate limit 초과. {wait_time}초 후 재시도...")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
raise Exception(
|
||||
f"[RateLimit] Rate limit 초과 (최대 재시도 횟수 도달) | retry_after={retry_after}s"
|
||||
)
|
||||
|
||||
# 서버 에러 재시도 (5xx)
|
||||
if response.status_code >= 500:
|
||||
if attempt < self.max_retries:
|
||||
wait_time = retry_base_delay * (2**attempt)
|
||||
logger.warning(f"서버 에러 {response.status_code}. {wait_time}초 후 재시도...")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
|
||||
# JSON 파싱
|
||||
response_data = response.json()
|
||||
|
||||
# API 에러 체크 (Instagram API는 200 응답에도 error 포함 가능)
|
||||
if "error" in response_data:
|
||||
error_response = ErrorResponse.model_validate(response_data)
|
||||
err = error_response.error
|
||||
logger.error(f"[API Error] code={err.code}, message={err.message}")
|
||||
error_msg = f"[InstagramAPI] {err.message} | code={err.code}"
|
||||
if err.error_subcode:
|
||||
error_msg += f" | subcode={err.error_subcode}"
|
||||
if err.fbtrace_id:
|
||||
error_msg += f" | fbtrace_id={err.fbtrace_id}"
|
||||
raise Exception(error_msg)
|
||||
|
||||
return response_data
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
last_exception = e
|
||||
if attempt < self.max_retries:
|
||||
wait_time = retry_base_delay * (2**attempt)
|
||||
logger.warning(f"HTTP 에러: {e}. {wait_time}초 후 재시도...")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
raise
|
||||
|
||||
raise last_exception or Exception("[InstagramAPI] 최대 재시도 횟수 초과")
|
||||
|
||||
async def _wait_for_container(
|
||||
self,
|
||||
container_id: str,
|
||||
timeout: Optional[float] = None,
|
||||
) -> MediaContainer:
|
||||
"""컨테이너 상태가 FINISHED가 될 때까지 대기"""
|
||||
timeout = timeout or self.container_timeout
|
||||
start_time = time.monotonic()
|
||||
|
||||
logger.debug(f"[Container] 대기 시작: {container_id}, timeout={timeout}s")
|
||||
|
||||
while True:
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= timeout:
|
||||
raise Exception(
|
||||
f"[ContainerTimeout] 컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}"
|
||||
)
|
||||
|
||||
response = await self._request(
|
||||
method="GET",
|
||||
endpoint=container_id,
|
||||
params={"fields": "status_code,status"},
|
||||
)
|
||||
|
||||
container = MediaContainer.model_validate(response)
|
||||
logger.debug(f"[Container] status={container.status_code}, elapsed={elapsed:.1f}s")
|
||||
|
||||
if container.is_finished:
|
||||
logger.info(f"[Container] 완료: {container_id}")
|
||||
return container
|
||||
|
||||
if container.is_error:
|
||||
raise Exception(f"[ContainerStatus] 컨테이너 처리 실패: {container.status}")
|
||||
|
||||
await asyncio.sleep(self.container_poll_interval)
|
||||
|
||||
async def get_account_id(self) -> str:
|
||||
"""계정 ID 조회 (접속 테스트용)"""
|
||||
if self._account_id:
|
||||
return self._account_id
|
||||
|
||||
async with self._account_id_lock:
|
||||
if self._account_id:
|
||||
return self._account_id
|
||||
|
||||
response = await self._request(
|
||||
method="GET",
|
||||
endpoint="me",
|
||||
params={"fields": "id"},
|
||||
)
|
||||
account_id: str = response["id"]
|
||||
self._account_id = account_id
|
||||
logger.debug(f"[Account] ID 조회 완료: {account_id}")
|
||||
return account_id
|
||||
|
||||
async def get_media(self, media_id: str) -> Media:
|
||||
"""
|
||||
미디어 상세 조회
|
||||
|
||||
Args:
|
||||
media_id: 미디어 ID
|
||||
|
||||
Returns:
|
||||
Media: 미디어 상세 정보
|
||||
"""
|
||||
logger.info(f"[get_media] media_id={media_id}")
|
||||
|
||||
response = await self._request(
|
||||
method="GET",
|
||||
endpoint=media_id,
|
||||
params={
|
||||
"fields": "id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count",
|
||||
},
|
||||
)
|
||||
|
||||
result = Media.model_validate(response)
|
||||
logger.info(f"[get_media] 완료: type={result.media_type}, permalink={result.permalink}")
|
||||
return result
|
||||
|
||||
async def publish_video(
|
||||
self,
|
||||
video_url: str,
|
||||
caption: Optional[str] = None,
|
||||
share_to_feed: bool = True,
|
||||
) -> Media:
|
||||
"""
|
||||
비디오/릴스 게시
|
||||
|
||||
Args:
|
||||
video_url: 공개 접근 가능한 비디오 URL (MP4 권장)
|
||||
caption: 게시물 캡션
|
||||
share_to_feed: 피드에 공유 여부
|
||||
|
||||
Returns:
|
||||
Media: 게시된 미디어 정보
|
||||
"""
|
||||
logger.info(f"[publish_video] 시작: {video_url[:50]}...")
|
||||
account_id = await self.get_account_id()
|
||||
|
||||
# Step 1: Container 생성
|
||||
container_params: dict[str, Any] = {
|
||||
"media_type": "REELS",
|
||||
"video_url": video_url,
|
||||
"share_to_feed": str(share_to_feed).lower(),
|
||||
}
|
||||
if caption:
|
||||
container_params["caption"] = caption
|
||||
|
||||
container_response = await self._request(
|
||||
method="POST",
|
||||
endpoint=f"{account_id}/media",
|
||||
params=container_params,
|
||||
)
|
||||
container_id = container_response["id"]
|
||||
logger.debug(f"[publish_video] Container 생성: {container_id}")
|
||||
|
||||
# Step 2: Container 상태 대기 (비디오는 더 오래 걸림)
|
||||
await self._wait_for_container(container_id, timeout=self.container_timeout * 2)
|
||||
|
||||
# Step 3: 게시
|
||||
publish_response = await self._request(
|
||||
method="POST",
|
||||
endpoint=f"{account_id}/media_publish",
|
||||
params={"creation_id": container_id},
|
||||
)
|
||||
media_id = publish_response["id"]
|
||||
|
||||
result = await self.get_media(media_id)
|
||||
logger.info(f"[publish_video] 완료: {result.permalink}")
|
||||
return result
|
||||
|
|
@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
|
|||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Literal
|
||||
|
||||
from app.utils.timezone import today_str
|
||||
from config import log_settings
|
||||
|
||||
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
|
||||
|
|
@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler:
|
|||
global _shared_file_handler
|
||||
|
||||
if _shared_file_handler is None:
|
||||
today = today_str()
|
||||
today = datetime.today().strftime("%Y-%m-%d")
|
||||
log_file = LOG_DIR / f"{today}_app.log"
|
||||
|
||||
_shared_file_handler = RotatingFileHandler(
|
||||
|
|
@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler:
|
|||
global _shared_error_handler
|
||||
|
||||
if _shared_error_handler is None:
|
||||
today = today_str()
|
||||
today = datetime.today().strftime("%Y-%m-%d")
|
||||
log_file = LOG_DIR / f"{today}_error.log"
|
||||
|
||||
_shared_error_handler = RotatingFileHandler(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
from urllib import parse
|
||||
import time
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("pwscraper")
|
||||
|
||||
class NvMapPwScraper():
|
||||
class nvMapPwScraper():
|
||||
# cls vars
|
||||
is_ready = False
|
||||
_playwright = None
|
||||
|
|
@ -15,8 +10,7 @@ class NvMapPwScraper():
|
|||
_context = None
|
||||
_win_width = 1280
|
||||
_win_height = 720
|
||||
_max_retry = 3
|
||||
_timeout = 60 # place id timeout threshold seconds
|
||||
_max_retry = 30 # place id timeout threshold seconds
|
||||
|
||||
# instance var
|
||||
page = None
|
||||
|
|
@ -96,56 +90,24 @@ patchedGetter.toString();''')
|
|||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||
|
||||
async def get_place_id_url(self, selected):
|
||||
count = 0
|
||||
get_place_id_url_start = time.perf_counter()
|
||||
while (count <= self._max_retry):
|
||||
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||
encoded_query = parse.quote(f"{address} {title}")
|
||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||
|
||||
wait_first_start = time.perf_counter()
|
||||
|
||||
try:
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||
except:
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||
|
||||
|
||||
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
|
||||
|
||||
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
|
||||
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
|
||||
wait_forced_correct_start = time.perf_counter()
|
||||
|
||||
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||
try:
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||
except:
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||
|
||||
wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000
|
||||
logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
count += 1
|
||||
|
||||
logger.error("[ERROR] Not found url for {selected}")
|
||||
|
||||
return None # 404
|
||||
|
||||
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||
encoded_query = parse.quote(f"{address} {title}")
|
||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||
|
||||
# if (count == self._max_retry / 2):
|
||||
# raise Exception("Failed to identify place id. loading timeout")
|
||||
# else:
|
||||
# raise Exception("Failed to identify place id. item is ambiguous")
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
if (count == self._max_retry / 2):
|
||||
raise Exception("Failed to identify place id. loading timeout")
|
||||
else:
|
||||
raise Exception("Failed to identify place id. item is ambiguous")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class NvMapScraper:
|
|||
|
||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||
REQUEST_TIMEOUT = 120 # 초
|
||||
data_source_identifier = "nv"
|
||||
|
||||
OVERVIEW_QUERY: str = """
|
||||
query getAccommodation($id: String!, $deviceType: String) {
|
||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||
|
|
@ -99,8 +99,6 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
data = await self._call_get_accommodation(place_id)
|
||||
self.rawdata = data
|
||||
fac_data = await self._get_facility_string(place_id)
|
||||
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
||||
self.place_id = self.data_source_identifier + place_id
|
||||
self.rawdata["facilities"] = fac_data
|
||||
self.image_link_list = [
|
||||
nv_image["origin"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"model": "gpt-5-mini",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info",
|
||||
"marketing_intelligence_summary",
|
||||
"language",
|
||||
"promotional_expression_example",
|
||||
"timing_rules"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "lyric",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lyric": {
|
||||
"type": "string"
|
||||
},
|
||||
"suno_prompt":{
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lyric", "suno_prompt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ You are a content marketing expert, brand strategist, and creative songwriter
|
|||
specializing in Korean pension / accommodation businesses.
|
||||
You create lyrics strictly based on Brand & Marketing Intelligence analysis
|
||||
and optimized for viral short-form video content.
|
||||
Marketing Intelligence Report is background reference.
|
||||
|
||||
[INPUT]
|
||||
Business Name: {customer_name}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"model": "gpt-5-mini",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "report",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail_title": {
|
||||
"type": "string"
|
||||
},
|
||||
"detail_description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"detail_title",
|
||||
"detail_description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary",
|
||||
"details"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"contents_advise": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags",
|
||||
"contents_advise"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
|
||||
[Role & Objective]
|
||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[Core Analysis Requirements]
|
||||
Analyze the property based on:
|
||||
Location, concept, photos, online presence, and nearby environment
|
||||
Target customer behavior and reservation decision factors
|
||||
Include:
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Competitive landscape (direct & indirect competitors)
|
||||
- Market positioning
|
||||
|
||||
[Key Selling Point Structuring – UI Optimized]
|
||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
||||
Rules:
|
||||
Focus only on factors that directly influence booking decisions
|
||||
Each selling point must be concise and visually scannable
|
||||
Language must be reusable for ads, short-form videos, and listing headlines
|
||||
Avoid full sentences in descriptions; use short selling phrases
|
||||
|
||||
Output format:
|
||||
[Category]
|
||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
||||
One-line selling phrase (not a full sentence)
|
||||
Limit:
|
||||
5 to 8 Key Selling Points only
|
||||
|
||||
[Content & Automation Readiness Check]
|
||||
Ensure that:
|
||||
Each tag keyword can directly map to a content theme
|
||||
Each selling phrase can be used as:
|
||||
- Video hook
|
||||
- Image headline
|
||||
- Ad copy snippet
|
||||
|
||||
|
||||
[Tag Generation Rules]
|
||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
||||
- The number of tags must be **exactly 5**
|
||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
||||
- The following categories must be **balanced and all represented**:
|
||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
||||
|
||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
||||
- The final output must strictly follow the JSON format below, with no additional text
|
||||
|
||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"model": "gpt-5.2",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "report",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "string"
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import os, json
|
||||
from abc import ABCMeta
|
||||
from config import prompt_settings
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("prompt")
|
||||
|
||||
class Prompt():
|
||||
prompt_name : str # ex) marketing_prompt
|
||||
prompt_template_path : str #프롬프트 경로
|
||||
prompt_template : str # fstring 포맷
|
||||
prompt_input : list
|
||||
prompt_output : dict
|
||||
prompt_model : str
|
||||
|
||||
def __init__(self, prompt_name, prompt_template_path):
|
||||
self.prompt_name = prompt_name
|
||||
self.prompt_template_path = prompt_template_path
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
|
||||
def _reload_prompt(self):
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
|
||||
def read_prompt(self) -> tuple[str, dict]:
|
||||
template_text_path = self.prompt_template_path + ".txt"
|
||||
prompt_dict_path = self.prompt_template_path + ".json"
|
||||
with open(template_text_path, "r") as fp:
|
||||
prompt_template = fp.read()
|
||||
with open(prompt_dict_path, "r") as fp:
|
||||
prompt_dict = json.load(fp)
|
||||
|
||||
return prompt_template, prompt_dict
|
||||
|
||||
def build_prompt(self, input_data:dict) -> str:
|
||||
self.check_input(input_data)
|
||||
build_template = self.prompt_template
|
||||
logger.debug(f"build_template: {build_template}")
|
||||
logger.debug(f"input_data: {input_data}")
|
||||
build_template = build_template.format(**input_data)
|
||||
return build_template
|
||||
|
||||
def check_input(self, input_data:dict) -> bool:
|
||||
missing_variables = input_data.keys() - set(self.prompt_input)
|
||||
if missing_variables:
|
||||
raise Exception(f"missing_variable for prompt {self.prompt_name} : {missing_variables}")
|
||||
|
||||
flooding_variables = set(self.prompt_input) - input_data.keys()
|
||||
if flooding_variables:
|
||||
raise Exception(f"flooding_variables for prompt {self.prompt_name} : {flooding_variables}")
|
||||
return True
|
||||
|
||||
marketing_prompt = Prompt(
|
||||
prompt_name=prompt_settings.MARKETING_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_NAME)
|
||||
)
|
||||
|
||||
summarize_prompt = Prompt(
|
||||
prompt_name=prompt_settings.SUMMARIZE_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.SUMMARIZE_PROMPT_NAME)
|
||||
)
|
||||
|
||||
lyric_prompt = Prompt(
|
||||
prompt_name=prompt_settings.LYLIC_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYLIC_PROMPT_NAME)
|
||||
)
|
||||
|
||||
def reload_all_prompt():
|
||||
marketing_prompt._reload_prompt()
|
||||
summarize_prompt._reload_prompt()
|
||||
lyric_prompt._reload_prompt()
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"prompt_variables": [
|
||||
"report",
|
||||
"selling_points"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "tags",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag_keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"tag_keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
|
||||
입력 :
|
||||
분석 보고서
|
||||
{report}
|
||||
|
||||
셀링 포인트
|
||||
{selling_points}
|
||||
|
||||
위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.
|
||||
|
||||
조건:
|
||||
각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것
|
||||
태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여
|
||||
- 3 ~ 6단어 이내
|
||||
- 명사 또는 명사형 키워드로 작성
|
||||
- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것
|
||||
- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함
|
||||
- 전체 셀링 포인트 개수는 5~7개로 제한
|
||||
|
||||
출력 형식:
|
||||
[카테고리명]
|
||||
(태그 키워드)
|
||||
- 한 줄 설명 문구
|
||||
|
||||
예시:
|
||||
[공간 정체성]
|
||||
(100년 적산가옥 · 시간의 결)
|
||||
- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간
|
||||
|
||||
[입지 & 희소성]
|
||||
(말랭이마을 · 로컬 히든플레이스)
|
||||
- 관광지가 아닌, 군산을 아는 사람의 선택
|
||||
|
||||
[프라이버시]
|
||||
(독채 숙소 · 프라이빗 스테이)
|
||||
- 누구의 방해도 없는 완전한 휴식 구조
|
||||
|
||||
[비주얼 경쟁력]
|
||||
(감성 인테리어 · 자연광 스폿)
|
||||
- 찍는 순간 콘텐츠가 되는 공간 설계
|
||||
|
||||
[타깃 최적화]
|
||||
(커플 · 소규모 여행)
|
||||
- 둘에게 가장 이상적인 공간 밀도
|
||||
|
||||
[체류 경험]
|
||||
(아무것도 안 해도 되는 하루)
|
||||
- 일정 없이도 만족되는 하루 루틴
|
||||
|
||||
[브랜드 포지션]
|
||||
(호텔도 펜션도 아닌 아지트)
|
||||
- 다시 돌아오고 싶은 개인적 장소
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"model": "gpt-5-mini",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info",
|
||||
"marketing_intelligence_summary",
|
||||
"language",
|
||||
"promotional_expression_example",
|
||||
"timing_rules"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "lyric",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lyric": {
|
||||
"type": "string"
|
||||
},
|
||||
"suno_prompt":{
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lyric", "suno_prompt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
|
||||
[ROLE]
|
||||
You are a content marketing expert, brand strategist, and creative songwriter
|
||||
specializing in Korean pension / accommodation businesses.
|
||||
You create lyrics strictly based on Brand & Marketing Intelligence analysis
|
||||
and optimized for viral short-form video content.
|
||||
|
||||
[INPUT]
|
||||
Business Name: {customer_name}
|
||||
Region: {region}
|
||||
Region Details: {detail_region_info}
|
||||
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
|
||||
Output Language: {language}
|
||||
|
||||
[INTERNAL ANALYSIS – DO NOT OUTPUT]
|
||||
Internally analyze the following to guide all creative decisions:
|
||||
- Core brand identity and positioning
|
||||
- Emotional hooks derived from selling points
|
||||
- Target audience lifestyle, desires, and travel motivation
|
||||
- Regional atmosphere and symbolic imagery
|
||||
- How the stay converts into “shareable moments”
|
||||
- Which selling points must surface implicitly in lyrics
|
||||
|
||||
[LYRICS & MUSIC CREATION TASK]
|
||||
Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:
|
||||
- Original promotional lyrics
|
||||
- Music attributes for AI music generation (Suno-compatible prompt)
|
||||
The output must be designed for VIRAL DIGITAL CONTENT
|
||||
(short-form video, reels, ads).
|
||||
|
||||
[LYRICS REQUIREMENTS]
|
||||
Mandatory Inclusions:
|
||||
- Business name
|
||||
- Region name
|
||||
- Promotion subject
|
||||
- Promotional expressions including:
|
||||
{promotional_expression_example}
|
||||
|
||||
Content Rules:
|
||||
- Lyrics must be emotionally driven, not descriptive listings
|
||||
- Selling points must be IMPLIED, not explained
|
||||
- Must sound natural when sung
|
||||
- Must feel like a lifestyle moment, not an advertisement
|
||||
|
||||
Tone & Style:
|
||||
- Warm, emotional, and aspirational
|
||||
- Trendy, viral-friendly phrasing
|
||||
- Calm but memorable hooks
|
||||
- Suitable for travel / stay-related content
|
||||
|
||||
[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]
|
||||
After the lyrics, generate a concise music prompt including:
|
||||
Song mood (emotional keywords)
|
||||
BPM range
|
||||
Recommended genres (max 2)
|
||||
Key musical motifs or instruments
|
||||
Overall vibe (1 short sentence)
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE 100% WRITTEN IN {language}.
|
||||
no mixed languages
|
||||
All names, places, and expressions must be in {language}
|
||||
Any violation invalidates the entire output
|
||||
|
||||
[OUTPUT RULES – STRICT]
|
||||
{timing_rules}
|
||||
|
||||
No explanations
|
||||
No headings
|
||||
No bullet points
|
||||
No analysis
|
||||
No extra text
|
||||
|
||||
[FAILURE FORMAT]
|
||||
If generation is impossible:
|
||||
ERROR: Brief reason in English
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"model": "gpt-5.2",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "report",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "string"
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
|
||||
[Role & Objective]
|
||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[Core Analysis Requirements]
|
||||
Analyze the property based on:
|
||||
Location, concept, and nearby environment
|
||||
Target customer behavior and reservation decision factors
|
||||
Include:
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Competitive landscape (direct & indirect competitors)
|
||||
- Market positioning
|
||||
|
||||
[Key Selling Point Structuring – UI Optimized]
|
||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
||||
Rules:
|
||||
Focus only on factors that directly influence booking decisions
|
||||
Each selling point must be concise and visually scannable
|
||||
Language must be reusable for ads, short-form videos, and listing headlines
|
||||
Avoid full sentences in descriptions; use short selling phrases
|
||||
Do not provide in report
|
||||
|
||||
Output format:
|
||||
[Category]
|
||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
||||
One-line selling phrase (not a full sentence)
|
||||
Limit:
|
||||
5 to 8 Key Selling Points only
|
||||
Do not provide in report
|
||||
|
||||
[Content & Automation Readiness Check]
|
||||
Ensure that:
|
||||
Each tag keyword can directly map to a content theme
|
||||
Each selling phrase can be used as:
|
||||
- Video hook
|
||||
- Image headline
|
||||
- Ad copy snippet
|
||||
|
||||
|
||||
[Tag Generation Rules]
|
||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
||||
- The number of tags must be **exactly 5**
|
||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
||||
- The following categories must be **balanced and all represented**:
|
||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
||||
|
||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
||||
- The final output must strictly follow the JSON format below, with no additional text
|
||||
|
||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
|
||||
[Role & Objective]
|
||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[Core Analysis Requirements]
|
||||
Analyze the property based on:
|
||||
Location, concept, photos, online presence, and nearby environment
|
||||
Target customer behavior and reservation decision factors
|
||||
Include:
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Competitive landscape (direct & indirect competitors)
|
||||
- Market positioning
|
||||
|
||||
[Key Selling Point Structuring – UI Optimized]
|
||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
||||
Rules:
|
||||
Focus only on factors that directly influence booking decisions
|
||||
Each selling point must be concise and visually scannable
|
||||
Language must be reusable for ads, short-form videos, and listing headlines
|
||||
Avoid full sentences in descriptions; use short selling phrases
|
||||
|
||||
Output format:
|
||||
[Category]
|
||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
||||
One-line selling phrase (not a full sentence)
|
||||
Limit:
|
||||
5 to 8 Key Selling Points only
|
||||
|
||||
[Content & Automation Readiness Check]
|
||||
Ensure that:
|
||||
Each tag keyword can directly map to a content theme
|
||||
Each selling phrase can be used as:
|
||||
- Video hook
|
||||
- Image headline
|
||||
- Ad copy snippet
|
||||
|
||||
|
||||
[Tag Generation Rules]
|
||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
||||
- The number of tags must be **exactly 5**
|
||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
||||
- The following categories must be **balanced and all represented**:
|
||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
||||
|
||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
||||
- The final output must strictly follow the JSON format below, with no additional text
|
||||
|
||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
||||
|
|
@ -1,65 +1,76 @@
|
|||
import os, json
|
||||
from pydantic import BaseModel
|
||||
from abc import ABCMeta
|
||||
from config import prompt_settings
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.prompts.schemas import *
|
||||
|
||||
logger = get_logger("prompt")
|
||||
|
||||
class Prompt():
|
||||
prompt_name : str # ex) marketing_prompt
|
||||
prompt_template_path : str #프롬프트 경로
|
||||
prompt_template : str # fstring 포맷
|
||||
prompt_model : str
|
||||
|
||||
prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
|
||||
prompt_output_class = BaseModel
|
||||
prompt_input : list
|
||||
prompt_output : dict
|
||||
prompt_model : str
|
||||
|
||||
def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
|
||||
def __init__(self, prompt_name, prompt_template_path):
|
||||
self.prompt_name = prompt_name
|
||||
self.prompt_template_path = prompt_template_path
|
||||
self.prompt_input_class = prompt_input_class
|
||||
self.prompt_output_class = prompt_output_class
|
||||
self.prompt_template = self.read_prompt()
|
||||
self.prompt_model = prompt_model
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
|
||||
def _reload_prompt(self):
|
||||
self.prompt_template = self.read_prompt()
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
|
||||
def read_prompt(self) -> tuple[str, dict]:
|
||||
with open(self.prompt_template_path, "r") as fp:
|
||||
template_text_path = self.prompt_template_path + ".txt"
|
||||
prompt_dict_path = self.prompt_template_path + ".json"
|
||||
with open(template_text_path, "r") as fp:
|
||||
prompt_template = fp.read()
|
||||
with open(prompt_dict_path, "r") as fp:
|
||||
prompt_dict = json.load(fp)
|
||||
|
||||
return prompt_template
|
||||
return prompt_template, prompt_dict
|
||||
|
||||
def build_prompt(self, input_data:dict) -> str:
|
||||
verified_input = self.prompt_input_class(**input_data)
|
||||
self.check_input(input_data)
|
||||
build_template = self.prompt_template
|
||||
build_template = build_template.format(**verified_input.model_dump())
|
||||
logger.debug(f"build_template: {build_template}")
|
||||
logger.debug(f"input_data: {input_data}")
|
||||
build_template = build_template.format(**input_data)
|
||||
return build_template
|
||||
|
||||
def check_input(self, input_data:dict) -> bool:
|
||||
missing_variables = input_data.keys() - set(self.prompt_input)
|
||||
if missing_variables:
|
||||
raise Exception(f"missing_variable for prompt {self.prompt_name} : {missing_variables}")
|
||||
|
||||
flooding_variables = set(self.prompt_input) - input_data.keys()
|
||||
if flooding_variables:
|
||||
raise Exception(f"flooding_variables for prompt {self.prompt_name} : {flooding_variables}")
|
||||
return True
|
||||
|
||||
marketing_prompt = Prompt(
|
||||
prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
|
||||
prompt_input_class = MarketingPromptInput,
|
||||
prompt_output_class = MarketingPromptOutput,
|
||||
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
|
||||
prompt_name=prompt_settings.MARKETING_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_NAME)
|
||||
)
|
||||
|
||||
summarize_prompt = Prompt(
|
||||
prompt_name=prompt_settings.SUMMARIZE_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.SUMMARIZE_PROMPT_NAME)
|
||||
)
|
||||
|
||||
lyric_prompt = Prompt(
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
|
||||
prompt_input_class = LyricPromptInput,
|
||||
prompt_output_class = LyricPromptOutput,
|
||||
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
|
||||
)
|
||||
|
||||
yt_upload_prompt = Prompt(
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.YOUTUBE_PROMPT_FILE_NAME),
|
||||
prompt_input_class = YTUploadPromptInput,
|
||||
prompt_output_class = YTUploadPromptOutput,
|
||||
prompt_model = prompt_settings.YOUTUBE_PROMPT_MODEL
|
||||
prompt_name=prompt_settings.LYLIC_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYLIC_PROMPT_NAME)
|
||||
)
|
||||
|
||||
def reload_all_prompt():
|
||||
marketing_prompt._reload_prompt()
|
||||
lyric_prompt._reload_prompt()
|
||||
yt_upload_prompt._reload_prompt()
|
||||
summarize_prompt._reload_prompt()
|
||||
lyric_prompt._reload_prompt()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue