Compare commits

..

No commits in common. "main" and "20260126-v1.1.0" have entirely different histories.

141 changed files with 2611 additions and 18042 deletions

4
.gitignore vendored
View File

@ -46,7 +46,3 @@ logs/
._* ._*
.Spotlight-V100 .Spotlight-V100
.Trashes .Trashes
*.yml
Dockerfile
.dockerignore

View File

@ -1 +1 @@
3.13.11 3.14

View File

@ -161,9 +161,6 @@ uv sync
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요) # 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
uv sync --active uv sync --active
playwright install
playwright install-deps
``` ```
### 서버 실행 ### 서버 실행

View File

@ -5,8 +5,6 @@ from app.database.session import engine
from app.home.api.home_admin import ImageAdmin, ProjectAdmin from app.home.api.home_admin import ImageAdmin, ProjectAdmin
from app.lyric.api.lyrics_admin import LyricAdmin from app.lyric.api.lyrics_admin import LyricAdmin
from app.song.api.song_admin import SongAdmin 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 app.video.api.video_admin import VideoAdmin
from config import prj_settings from config import prj_settings
@ -37,12 +35,4 @@ def init_admin(
# 영상 관리 # 영상 관리
admin.add_view(VideoAdmin) admin.add_view(VideoAdmin)
# 사용자 관리
admin.add_view(UserAdmin)
admin.add_view(RefreshTokenAdmin)
admin.add_view(SocialAccountAdmin)
# SNS 관리
admin.add_view(SNSUploadTaskAdmin)
return admin return admin

View File

@ -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)}",
)

View File

@ -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

View File

@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.nvMapPwScraper import NvMapPwScraper
logger = get_logger("core") logger = get_logger("core")
@ -24,7 +24,6 @@ async def lifespan(app: FastAPI):
await create_db_tables() await create_db_tables()
logger.info("Database tables created (DEBUG mode)") logger.info("Database tables created (DEBUG mode)")
await NvMapPwScraper.initiate_scraper()
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error("Database initialization timed out") logger.error("Database initialization timed out")
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass # 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass

View File

@ -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) @app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
def internal_server_error_handler(request, exception): def internal_server_error_handler(request, exception):
# 에러 메시지 로깅 (한글 포함 가능)
logger.error(f"Internal Server Error: {exception}")
return JSONResponse( return JSONResponse(
content={"detail": "Something went wrong..."}, content={"detail": "Something went wrong..."},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
headers={
"X-Error": f"{exception}",
}
) )

View File

@ -4,6 +4,11 @@ from redis.asyncio import Redis
from app.config import db_settings 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( _shipment_verification_codes = Redis(
host=db_settings.REDIS_HOST, host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT, port=db_settings.REDIS_PORT,
@ -11,10 +16,15 @@ _shipment_verification_codes = Redis(
decode_responses=True, 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): async def add_shipment_verification_code(id: UUID, code: int):
await _shipment_verification_codes.set(str(id), code) await _shipment_verification_codes.set(str(id), code)
async def get_shipment_verification_code(id: UUID) -> str: 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)))

View File

@ -72,37 +72,18 @@ async def create_db_tables():
import asyncio import asyncio
# 모델 import (테이블 메타데이터 등록용) # 모델 import (테이블 메타데이터 등록용)
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401 # 주의: User를 먼저 import해야 UserProject가 User를 참조할 수 있음
from app.home.models import Image, Project, MarketingIntel # noqa: F401 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.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.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...") logger.info("Creating database tables...")
async with asyncio.timeout(10): async with asyncio.timeout(10):
async with engine.begin() as connection: async with engine.begin() as connection:
await connection.run_sync( await connection.run_sync(Base.metadata.create_all)
lambda conn: Base.metadata.create_all(conn, tables=tables_to_create)
)
# FastAPI 의존성용 세션 제너레이터 # FastAPI 의존성용 세션 제너레이터
@ -111,24 +92,22 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
pool = engine.pool pool = engine.pool
# 커넥션 풀 상태 로깅 (디버깅용) # 커넥션 풀 상태 로깅 (디버깅용)
# logger.debug( logger.debug(
# f"[get_session] ACQUIRE - pool_size: {pool.size()}, " f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, " f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
# f"overflow: {pool.overflow()}" f"overflow: {pool.overflow()}"
# ) )
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
acquire_time = time.perf_counter() acquire_time = time.perf_counter()
# logger.debug( logger.debug(
# f"[get_session] Session acquired in " f"[get_session] Session acquired in "
# f"{(acquire_time - start_time)*1000:.1f}ms" f"{(acquire_time - start_time)*1000:.1f}ms"
# ) )
try: try:
yield session yield session
except Exception as e: except Exception as e:
import traceback
await session.rollback() await session.rollback()
logger.error(traceback.format_exc())
logger.error( logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
@ -136,10 +115,10 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
raise e raise e
finally: finally:
total_time = time.perf_counter() - start_time total_time = time.perf_counter() - start_time
# logger.debug( logger.debug(
# f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, " f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
# f"pool_out: {pool.checkedout()}" f"pool_out: {pool.checkedout()}"
# ) )
# 백그라운드 태스크용 세션 제너레이터 # 백그라운드 태스크용 세션 제너레이터
@ -147,18 +126,18 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
start_time = time.perf_counter() start_time = time.perf_counter()
pool = background_engine.pool pool = background_engine.pool
# logger.debug( logger.debug(
# f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, " f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, " f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
# f"overflow: {pool.overflow()}" f"overflow: {pool.overflow()}"
# ) )
async with BackgroundSessionLocal() as session: async with BackgroundSessionLocal() as session:
acquire_time = time.perf_counter() acquire_time = time.perf_counter()
# logger.debug( logger.debug(
# f"[get_background_session] Session acquired in " f"[get_background_session] Session acquired in "
# f"{(acquire_time - start_time)*1000:.1f}ms" f"{(acquire_time - start_time)*1000:.1f}ms"
# ) )
try: try:
yield session yield session
except Exception as e: except Exception as e:
@ -171,11 +150,11 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
raise e raise e
finally: finally:
total_time = time.perf_counter() - start_time total_time = time.perf_counter() - start_time
# logger.debug( logger.debug(
# f"[get_background_session] RELEASE - " f"[get_background_session] RELEASE - "
# f"duration: {total_time*1000:.1f}ms, " f"duration: {total_time*1000:.1f}ms, "
# f"pool_out: {pool.checkedout()}" f"pool_out: {pool.checkedout()}"
# ) )
# 앱 종료 시 엔진 리소스 정리 함수 # 앱 종료 시 엔진 리소스 정리 함수

View File

@ -1,6 +1,6 @@
from sqladmin import ModelView from sqladmin import ModelView
from app.home.models import Image, Project from app.home.models import Image, Project, UserProject
class ProjectAdmin(ModelView, model=Project): class ProjectAdmin(ModelView, model=Project):
@ -100,3 +100,44 @@ class ImageAdmin(ModelView, model=Image):
"img_url": "이미지 URL", "img_url": "이미지 URL",
"created_at": "생성일시", "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": "프로젝트",
}

View File

@ -11,29 +11,22 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session, AsyncSessionLocal from app.database.session import get_session, AsyncSessionLocal
from app.home.models import Image, MarketingIntel from app.home.models import Image
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.home.schemas.home_schema import ( from app.home.schemas.home_schema import (
AutoCompleteRequest,
AccommodationSearchItem,
AccommodationSearchResponse,
CrawlingRequest, CrawlingRequest,
CrawlingResponse, CrawlingResponse,
ErrorResponse, ErrorResponse,
ImageUploadResponse, ImageUploadResponse,
ImageUploadResultItem, ImageUploadResultItem,
ImageUrlItem, ImageUrlItem,
MarketingAnalysis,
ProcessedInfo, ProcessedInfo,
# MarketingAnalysis,
) )
from app.home.services.naver_search import naver_search_client
from app.utils.upload_blob_as_request import AzureBlobUploader 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.common import generate_task_id
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.nvMapScraper import NvMapScraper, GraphQLException from app.utils.nvMapScraper import NvMapScraper, GraphQLException
from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt from app.utils.prompts.prompts import marketing_prompt
from config import MEDIA_ROOT from config import MEDIA_ROOT
@ -69,49 +62,7 @@ KOREAN_CITIES = [
] ]
# fmt: on # fmt: on
# router = APIRouter(tags=["Home"]) 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,
)
def _extract_region_from_address(road_address: str | None) -> str: 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"], tags=["Crawling"],
) )
async def crawling( async def crawling(request_body: CrawlingRequest):
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):
request_start = time.perf_counter() request_start = time.perf_counter()
logger.info("[crawling] ========== START ==========") logger.info("[crawling] ========== START ==========")
logger.info(f"[crawling] URL: {url[:80]}...") logger.info(f"[crawling] URL: {request_body.url[:80]}...")
# ========== Step 1: 네이버 지도 크롤링 ========== # ========== Step 1: 네이버 지도 크롤링 ==========
step1_start = time.perf_counter() step1_start = time.perf_counter()
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...") logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
try: try:
scraper = NvMapScraper(url) scraper = NvMapScraper(request_body.url)
await scraper.scrap() await scraper.scrap()
except GraphQLException as e: except GraphQLException as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
@ -286,16 +194,7 @@ async def _crawling_logic(
step3_3_start = time.perf_counter() step3_3_start = time.perf_counter()
structured_report = await chatgpt_service.generate_structured_output( structured_report = await chatgpt_service.generate_structured_output(
marketing_prompt, input_marketing_data 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 step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
logger.info( logger.info(
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)" f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
@ -318,11 +217,33 @@ async def _crawling_logic(
# marketing_analysis = MarketingAnalysis(**parsed) # marketing_analysis = MarketingAnalysis(**parsed)
logger.debug( 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 step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
logger.debug( logger.debug(
f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)" 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)" 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: except Exception as e:
step3_elapsed = (time.perf_counter() - step3_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.error( logger.error(
@ -350,7 +262,6 @@ async def _crawling_logic(
logger.exception("[crawling] Step 3 상세 오류:") logger.exception("[crawling] Step 3 상세 오류:")
# GPT 실패 시에도 크롤링 결과는 반환 # GPT 실패 시에도 크롤링 결과는 반환
marketing_analysis = None marketing_analysis = None
gpt_status = "failed"
else: else:
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.warning( logger.warning(
@ -370,43 +281,13 @@ async def _crawling_logic(
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms") logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
return { return {
"status": gpt_status if 'gpt_status' in locals() else "completed",
"image_list": scraper.image_link_list, "image_list": scraper.image_link_list,
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0, "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
"processed_info": processed_info, "processed_info": processed_info,
"marketing_analysis": marketing_analysis, "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: def _extract_image_name(url: str, index: int) -> str:
"""URL에서 이미지 이름 추출 또는 기본 이름 생성""" """URL에서 이미지 이름 추출 또는 기본 이름 생성"""
try: try:
@ -553,7 +434,6 @@ async def upload_images(
files: Optional[list[UploadFile]] = File( files: Optional[list[UploadFile]] = File(
default=None, description="이미지 바이너리 파일 목록" default=None, description="이미지 바이너리 파일 목록"
), ),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ImageUploadResponse: ) -> ImageUploadResponse:
"""이미지 업로드 (URL + 바이너리 파일)""" """이미지 업로드 (URL + 바이너리 파일)"""
@ -707,9 +587,6 @@ async def upload_images(
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다. 이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다. 바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 요청 방식 ## 요청 방식
multipart/form-data 형식으로 전송합니다. multipart/form-data 형식으로 전송합니다.
@ -736,13 +613,11 @@ jpg, jpeg, png, webp, heic, heif
```bash ```bash
# 바이너리 파일만 업로드 # 바이너리 파일만 업로드
curl -X POST "http://localhost:8000/image/upload/blob" \\ curl -X POST "http://localhost:8000/image/upload/blob" \\
-H "Authorization: Bearer {access_token}" \\
-F "files=@/path/to/image1.jpg" \\ -F "files=@/path/to/image1.jpg" \\
-F "files=@/path/to/image2.png" -F "files=@/path/to/image2.png"
# URL + 바이너리 파일 동시 업로드 # URL + 바이너리 파일 동시 업로드
curl -X POST "http://localhost:8000/image/upload/blob" \\ curl -X POST "http://localhost:8000/image/upload/blob" \\
-H "Authorization: Bearer {access_token}" \\
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\ -F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
-F "files=@/path/to/local_image.jpg" -F "files=@/path/to/local_image.jpg"
``` ```
@ -765,7 +640,6 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
responses={ responses={
200: {"description": "이미지 업로드 성공"}, 200: {"description": "이미지 업로드 성공"},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
401: {"description": "인증 실패 (토큰 없음/만료)"},
}, },
tags=["Image-Blob"], tags=["Image-Blob"],
openapi_extra={ openapi_extra={
@ -788,7 +662,6 @@ async def upload_images_blob(
default=None, default=None,
description="이미지 바이너리 파일 목록", description="이미지 바이너리 파일 목록",
), ),
current_user: User = Depends(get_current_user),
) -> ImageUploadResponse: ) -> ImageUploadResponse:
"""이미지 업로드 (URL + Azure Blob Storage) """이미지 업로드 (URL + Azure Blob Storage)
@ -867,7 +740,7 @@ async def upload_images_blob(
img_order = len(url_images) # URL 이미지 다음 순서부터 시작 img_order = len(url_images) # URL 이미지 다음 순서부터 시작
if valid_files_data: 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) total_files = len(valid_files_data)
for idx, (original_name, ext, file_content) in enumerate(valid_files_data): for idx, (original_name, ext, file_content) in enumerate(valid_files_data):

View File

@ -4,12 +4,13 @@ Home 모듈 SQLAlchemy 모델 정의
모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다. 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
- Project: 프로젝트(사용자 입력 이력) 관리 - Project: 프로젝트(사용자 입력 이력) 관리
- Image: 업로드된 이미지 URL 관리 - Image: 업로드된 이미지 URL 관리
- UserProject: User와 Project M:N 관계 중계 테이블
""" """
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -20,6 +21,122 @@ if TYPE_CHECKING:
from app.user.models import User from app.user.models import User
from app.video.models import Video 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): class Project(Base):
""" """
@ -32,15 +149,16 @@ class Project(Base):
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
store_name: 고객명 (필수) store_name: 고객명 (필수)
region: 지역명 (필수, : 서울, 부산, 대구 ) region: 지역명 (필수, : 서울, 부산, 대구 )
task_id: 작업 고유 식별자 (UUID7 형식, 36) task_id: 작업 고유 식별자 (UUID 형식, 36)
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식) detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
Relationships: Relationships:
owner: 프로젝트 소유자 (User, 1:N 관계)
lyrics: 생성된 가사 목록 lyrics: 생성된 가사 목록
songs: 생성된 노래 목록 songs: 생성된 노래 목록
videos: 최종 영상 결과 목록 videos: 최종 영상 결과 목록
user_projects: User와의 M:N 관계 (중계 테이블 통한 연결)
users: 프로젝트에 참여한 사용자 목록 (Association Proxy)
""" """
__tablename__ = "project" __tablename__ = "project"
@ -48,8 +166,6 @@ class Project(Base):
Index("idx_project_task_id", "task_id"), Index("idx_project_task_id", "task_id"),
Index("idx_project_store_name", "store_name"), Index("idx_project_store_name", "store_name"),
Index("idx_project_region", "region"), Index("idx_project_region", "region"),
Index("idx_project_user_uuid", "user_uuid"),
Index("idx_project_is_deleted", "is_deleted"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -68,37 +184,21 @@ class Project(Base):
store_name: Mapped[str] = mapped_column( store_name: Mapped[str] = mapped_column(
String(255), String(255),
nullable=False, nullable=False,
index=True,
comment="가게명", comment="가게명",
) )
region: Mapped[str] = mapped_column( region: Mapped[str] = mapped_column(
String(100), String(100),
nullable=False, nullable=False,
index=True,
comment="지역명 (예: 군산)", comment="지역명 (예: 군산)",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(36),
nullable=False, nullable=False,
unique=True, comment="프로젝트 작업 고유 식별자 (UUID)",
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",
) )
detail_region_info: Mapped[Optional[str]] = mapped_column( detail_region_info: Mapped[Optional[str]] = mapped_column(
@ -107,12 +207,6 @@ class Project(Base):
comment="상세 지역 정보", comment="상세 지역 정보",
) )
marketing_intelligence: Mapped[Optional[str]] = mapped_column(
Integer,
nullable=True,
comment="마케팅 인텔리전스 결과 정보 저장",
)
language: Mapped[str] = mapped_column( language: Mapped[str] = mapped_column(
String(50), String(50),
nullable=False, nullable=False,
@ -120,13 +214,6 @@ class Project(Base):
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", 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( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
@ -156,6 +243,20 @@ class Project(Base):
lazy="selectin", 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 __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str: def truncate(value: str | None, max_len: int = 10) -> str:
if value is None: if value is None:
@ -180,7 +281,7 @@ class Image(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
task_id: 이미지 업로드 작업 고유 식별자 (UUID7) task_id: 이미지 업로드 작업 고유 식별자 (UUID)
img_name: 이미지명 img_name: 이미지명
img_url: 이미지 URL (S3, CDN 등의 경로) img_url: 이미지 URL (S3, CDN 등의 경로)
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
@ -188,8 +289,6 @@ class Image(Base):
__tablename__ = "image" __tablename__ = "image"
__table_args__ = ( __table_args__ = (
Index("idx_image_task_id", "task_id"),
Index("idx_image_is_deleted", "is_deleted"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -208,7 +307,7 @@ class Image(Base):
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(36),
nullable=False, nullable=False,
comment="이미지 업로드 작업 고유 식별자 (UUID7)", comment="이미지 업로드 작업 고유 식별자 (UUID)",
) )
img_name: Mapped[str] = mapped_column( img_name: Mapped[str] = mapped_column(
@ -230,13 +329,6 @@ class Image(Base):
comment="이미지 순서", comment="이미지 순서",
) )
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
@ -255,66 +347,3 @@ class Image(Base):
return ( return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>" 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}')>"
)

View File

@ -1,7 +1,113 @@
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field 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): class CrawlingRequest(BaseModel):
"""크롤링 요청 스키마""" """크롤링 요청 스키마"""
@ -16,61 +122,6 @@ class CrawlingRequest(BaseModel):
url: str = Field(..., description="네이버 지도 장소 URL") 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): class ProcessedInfo(BaseModel):
"""가공된 장소 정보 스키마""" """가공된 장소 정보 스키마"""
@ -80,168 +131,25 @@ class ProcessedInfo(BaseModel):
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)") detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
# class MarketingAnalysisDetail(BaseModel): class MarketingAnalysis(BaseModel):
# detail_title : str = Field(..., description="디테일 카테고리 이름") """마케팅 분석 결과 스키마"""
# detail_description : str = Field(..., description="해당 항목 설명")
# class MarketingAnalysisReport(BaseModel): report: str = Field(..., description="마케팅 분석 리포트")
# """마케팅 분석 리포트 스키마""" tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
# summary : str = Field(..., description="비즈니스 한 줄 요약") facilities: list[str] = Field(default_factory=list, 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="추천 부대시설 목록")
class CrawlingResponse(BaseModel): 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_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: int = Field(..., description="이미지 개수") image_count: int = Field(..., description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field( processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
) )
marketing_analysis: Optional[MarketingPromptOutput] = Field( marketing_analysis: Optional[MarketingAnalysis] = Field(
None, description="마케팅 분석 결과 . 실패 시 null" None, description="마케팅 분석 결과 (report, tags, facilities)"
) )
m_id : int = Field(..., description="마케팅 분석 결과 ID")
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
@ -265,6 +173,29 @@ class ImageUrlItem(BaseModel):
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") 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): class ImageUploadResultItem(BaseModel):
"""업로드된 이미지 결과 아이템""" """업로드된 이미지 결과 아이템"""

View File

@ -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()

View File

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

View File

@ -8,11 +8,11 @@ Lyric API Router
- POST /lyric/generate: 가사 생성 - POST /lyric/generate: 가사 생성
- GET /lyric/status/{task_id}: 가사 생성 상태 조회 - GET /lyric/status/{task_id}: 가사 생성 상태 조회
- GET /lyric/{task_id}: 가사 상세 조회 - GET /lyric/{task_id}: 가사 상세 조회
- GET /lyric/list: 가사 목록 조회 (페이지네이션) - GET /lyrics: 가사 목록 조회 (페이지네이션)
사용 예시: 사용 예시:
from app.lyric.api.routers.v1.lyric import router from app.lyric.api.routers.v1.lyric import router
app.include_router(router) app.include_router(router, prefix="/api/v1")
다른 서비스에서 재사용: 다른 서비스에서 재사용:
# 이 파일의 헬퍼 함수들을 import하여 사용 가능 # 이 파일의 헬퍼 함수들을 import하여 사용 가능
@ -30,9 +30,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
from app.home.models import Project, MarketingIntel from app.home.models import Project
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.lyric.schemas.lyric import ( from app.lyric.schemas.lyric import (
GenerateLyricRequest, GenerateLyricRequest,
@ -48,7 +46,6 @@ from app.utils.pagination import PaginatedResponse, get_paginated
from app.utils.prompts.prompts import lyric_prompt from app.utils.prompts.prompts import lyric_prompt
import traceback as tb import traceback as tb
import json
# 로거 설정 # 로거 설정
logger = get_logger("lyric") logger = get_logger("lyric")
@ -175,9 +172,6 @@ async def get_lyric_by_task_id(
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다. 고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다. 백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 요청 필드 ## 요청 필드
- **task_id**: 작업 고유 식별자 (이미지 업로드 생성된 task_id, 필수) - **task_id**: 작업 고유 식별자 (이미지 업로드 생성된 task_id, 필수)
- **customer_name**: 고객명/가게명 (필수) - **customer_name**: 고객명/가게명 (필수)
@ -196,18 +190,16 @@ async def get_lyric_by_task_id(
- GET /lyric/status/{task_id} 처리 상태 확인 - GET /lyric/status/{task_id} 처리 상태 확인
- GET /lyric/{task_id} 생성된 가사 조회 - GET /lyric/{task_id} 생성된 가사 조회
## 사용 예시 (cURL) ## 사용 예시
```bash ```
curl -X POST "http://localhost:8000/lyric/generate" \\ POST /lyric/generate
-H "Authorization: Bearer {access_token}" \\ {
-H "Content-Type: application/json" \\
-d '{
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean" "language": "Korean"
}' }
``` ```
## 응답 예시 ## 응답 예시
@ -224,14 +216,12 @@ curl -X POST "http://localhost:8000/lyric/generate" \\
response_model=GenerateLyricResponse, response_model=GenerateLyricResponse,
responses={ responses={
200: {"description": "가사 생성 요청 접수 성공"}, 200: {"description": "가사 생성 요청 접수 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
500: {"description": "서버 내부 오류"}, 500: {"description": "서버 내부 오류"},
}, },
) )
async def generate_lyric( async def generate_lyric(
request_body: GenerateLyricRequest, request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse: ) -> GenerateLyricResponse:
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)""" """고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
@ -279,51 +269,34 @@ async def generate_lyric(
Full verse flow, immersive mood 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 = { lyric_input_data = {
"customer_name" : request_body.customer_name, "customer_name" : request_body.customer_name,
"region" : request_body.region, "region" : request_body.region,
"detail_region_info" : request_body.detail_region_info or "", "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, "language" : request_body.language,
"promotional_expression_example" : promotional_expressions[request_body.language], "promotional_expression_example" : promotional_expressions[request_body.language],
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나 "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
} }
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") #logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
# ========== Step 2: Project 조회 또는 생성 ========== # ========== Step 2: Project 테이블에 데이터 저장 ==========
step2_start = time.perf_counter() step2_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 2: Project 조회 또는 생성...") logger.debug(f"[generate_lyric] Step 2: Project 저장...")
# 기존 Project가 있는지 확인 (재생성 시 재사용) project = Project(
existing_project_result = await session.execute( store_name=request_body.customer_name,
select(Project).where(Project.task_id == task_id).limit(1) 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() session.add(project)
await session.commit()
if project: await session.refresh(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}")
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)") 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, task_id=task_id,
prompt=lyric_prompt, prompt=lyric_prompt,
lyric_input_data=lyric_input_data, lyric_input_data=lyric_input_data,
lyric_id=lyric.id,
) )
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step4_elapsed = (time.perf_counter() - step4_start) * 1000
@ -401,30 +373,24 @@ async def generate_lyric(
description=""" description="""
task_id로 가사 생성 작업의 현재 상태를 조회합니다. task_id로 가사 생성 작업의 현재 상태를 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 상태 값 ## 상태 값
- **processing**: 가사 생성 - **processing**: 가사 생성
- **completed**: 가사 생성 완료 - **completed**: 가사 생성 완료
- **failed**: 가사 생성 실패 - **failed**: 가사 생성 실패
## 사용 예시 (cURL) ## 사용 예시
```bash ```
curl -X GET "http://localhost:8000/lyric/status/019123ab-cdef-7890-abcd-ef1234567890" \\ GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
-H "Authorization: Bearer {access_token}"
``` ```
""", """,
response_model=LyricStatusResponse, response_model=LyricStatusResponse,
responses={ responses={
200: {"description": "상태 조회 성공"}, 200: {"description": "상태 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "해당 task_id를 찾을 수 없음"}, 404: {"description": "해당 task_id를 찾을 수 없음"},
}, },
) )
async def get_lyric_status( async def get_lyric_status(
task_id: str, task_id: str,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> LyricStatusResponse: ) -> LyricStatusResponse:
"""task_id로 가사 생성 작업 상태를 조회합니다.""" """task_id로 가사 생성 작업 상태를 조회합니다."""
@ -432,14 +398,11 @@ async def get_lyric_status(
@router.get( @router.get(
"/list", "s/",
summary="가사 목록 조회 (페이지네이션)", summary="가사 목록 조회 (페이지네이션)",
description=""" description="""
생성 완료된 가사를 페이지네이션으로 조회합니다. 생성 완료된 가사를 페이지네이션으로 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 파라미터 ## 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1) - **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 20, 최대: 100) - **page_size**: 페이지당 데이터 (기본값: 20, 최대: 100)
@ -453,19 +416,11 @@ async def get_lyric_status(
- **has_next**: 다음 페이지 존재 여부 - **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부 - **has_prev**: 이전 페이지 존재 여부
## 사용 예시 (cURL) ## 사용 예시
```bash ```
# 기본 조회 (1페이지, 20개) GET /lyrics/ # 기본 조회 (1페이지, 20개)
curl -X GET "http://localhost:8000/lyric/list" \\ GET /lyrics/?page=2 # 2페이지 조회
-H "Authorization: Bearer {access_token}" GET /lyrics/?page=1&page_size=50 # 50개씩 조회
# 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}"
``` ```
## 참고 ## 참고
@ -475,13 +430,11 @@ curl -X GET "http://localhost:8000/lyric/list?page=1&page_size=50" \\
response_model=PaginatedResponse[LyricListItem], response_model=PaginatedResponse[LyricListItem],
responses={ responses={
200: {"description": "가사 목록 조회 성공"}, 200: {"description": "가사 목록 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
}, },
) )
async def list_lyrics( async def list_lyrics(
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"), page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"), page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> PaginatedResponse[LyricListItem]: ) -> PaginatedResponse[LyricListItem]:
"""페이지네이션으로 완료된 가사 목록을 조회합니다.""" """페이지네이션으로 완료된 가사 목록을 조회합니다."""
@ -503,9 +456,6 @@ async def list_lyrics(
description=""" description="""
task_id로 생성된 가사의 상세 정보를 조회합니다. task_id로 생성된 가사의 상세 정보를 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 반환 정보 ## 반환 정보
- **id**: 가사 ID - **id**: 가사 ID
- **task_id**: 작업 고유 식별자 - **task_id**: 작업 고유 식별자
@ -515,22 +465,19 @@ task_id로 생성된 가사의 상세 정보를 조회합니다.
- **lyric_result**: 생성된 가사 (완료 ) - **lyric_result**: 생성된 가사 (완료 )
- **created_at**: 생성 일시 - **created_at**: 생성 일시
## 사용 예시 (cURL) ## 사용 예시
```bash ```
curl -X GET "http://localhost:8000/lyric/019123ab-cdef-7890-abcd-ef1234567890" \\ GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
-H "Authorization: Bearer {access_token}"
``` ```
""", """,
response_model=LyricDetailResponse, response_model=LyricDetailResponse,
responses={ responses={
200: {"description": "가사 조회 성공"}, 200: {"description": "가사 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "해당 task_id를 찾을 수 없음"}, 404: {"description": "해당 task_id를 찾을 수 없음"},
}, },
) )
async def get_lyric_detail( async def get_lyric_detail(
task_id: str, task_id: str,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> LyricDetailResponse: ) -> LyricDetailResponse:
"""task_id로 생성된 가사를 조회합니다.""" """task_id로 생성된 가사를 조회합니다."""

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List 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.dialects.mysql import LONGTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -23,7 +23,7 @@ class Lyric(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키) project_id: 연결된 Project의 id (외래키)
task_id: 가사 생성 작업의 고유 식별자 (UUID7 형식) task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
status: 처리 상태 (pending, processing, completed, failed ) status: 처리 상태 (pending, processing, completed, failed )
lyric_prompt: 가사 생성에 사용된 프롬프트 lyric_prompt: 가사 생성에 사용된 프롬프트
lyric_result: 생성된 가사 결과 (LONGTEXT로 가사 지원) lyric_result: 생성된 가사 결과 (LONGTEXT로 가사 지원)
@ -37,9 +37,6 @@ class Lyric(Base):
__tablename__ = "lyric" __tablename__ = "lyric"
__table_args__ = ( __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_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -59,13 +56,14 @@ class Lyric(Base):
Integer, Integer,
ForeignKey("project.id", ondelete="CASCADE"), ForeignKey("project.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Project의 id", comment="연결된 Project의 id",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(36),
nullable=False, nullable=False,
comment="가사 생성 작업 고유 식별자 (UUID7)", comment="가사 생성 작업 고유 식별자 (UUID)",
) )
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
@ -93,13 +91,6 @@ class Lyric(Base):
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", 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( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=True, nullable=True,

View File

@ -41,8 +41,7 @@ class GenerateLyricRequest(BaseModel):
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean"
"m_id" : 1
} }
""" """
@ -54,7 +53,6 @@ class GenerateLyricRequest(BaseModel):
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean",
"m_id" : 1
} }
} }
) )
@ -69,7 +67,6 @@ class GenerateLyricRequest(BaseModel):
default="Korean", default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
) )
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
class GenerateLyricResponse(BaseModel): class GenerateLyricResponse(BaseModel):
@ -111,33 +108,15 @@ class LyricStatusResponse(BaseModel):
Usage: Usage:
GET /lyric/status/{task_id} GET /lyric/status/{task_id}
Returns the current processing status of a lyric generation task. Returns the current processing status of a lyric generation task.
Status Values:
- processing: 가사 생성 진행
- completed: 가사 생성 완료
- failed: ChatGPT API 오류 또는 생성 실패
""" """
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"examples": [ "example": {
{ "task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"summary": "성공", "status": "completed",
"value": { "message": "가사 생성이 완료되었습니다.",
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431", }
"status": "completed",
"message": "가사 생성이 완료되었습니다.",
}
},
{
"summary": "실패",
"value": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"status": "failed",
"message": "가사 생성에 실패했습니다.",
}
}
]
} }
) )
@ -152,46 +131,26 @@ class LyricDetailResponse(BaseModel):
Usage: Usage:
GET /lyric/{task_id} GET /lyric/{task_id}
Returns the generated lyric content for a specific task. Returns the generated lyric content for a specific task.
Note:
- status가 "failed" 경우 lyric_result에 에러 메시지가 저장됩니다.
- 에러 메시지 형식: "ChatGPT Error: {message}" 또는 "Error: {message}"
""" """
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"examples": [ "example": {
{ "id": 1,
"summary": "성공", "task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"value": { "project_id": 1,
"id": 1, "status": "completed",
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
"project_id": 1, "created_at": "2024-01-15T12:00:00",
"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",
}
}
]
} }
) )
id: int = Field(..., description="가사 ID") id: int = Field(..., description="가사 ID")
task_id: str = Field(..., description="작업 고유 식별자") task_id: str = Field(..., description="작업 고유 식별자")
project_id: int = Field(..., description="프로젝트 ID") project_id: int = Field(..., description="프로젝트 ID")
status: str = Field(..., description="처리 상태 (processing, completed, failed)") status: str = Field(..., description="처리 상태")
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)") lyric_result: Optional[str] = Field(None, description="생성된 가사")
created_at: Optional[datetime] = Field(None, description="생성 일시") created_at: Optional[datetime] = Field(None, description="생성 일시")

View File

@ -11,7 +11,7 @@ from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.lyric.models import Lyric 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.prompts.prompts import Prompt
from app.utils.logger import get_logger from app.utils.logger import get_logger
@ -23,7 +23,6 @@ async def _update_lyric_status(
task_id: str, task_id: str,
status: str, status: str,
result: str | None = None, result: str | None = None,
lyric_id: int | None = None,
) -> bool: ) -> bool:
"""Lyric 테이블의 상태를 업데이트합니다. """Lyric 테이블의 상태를 업데이트합니다.
@ -31,26 +30,18 @@ async def _update_lyric_status(
task_id: 프로젝트 task_id task_id: 프로젝트 task_id
status: 변경할 상태 ("processing", "completed", "failed") status: 변경할 상태 ("processing", "completed", "failed")
result: 가사 결과 또는 에러 메시지 result: 가사 결과 또는 에러 메시지
lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용)
Returns: Returns:
bool: 업데이트 성공 여부 bool: 업데이트 성공 여부
""" """
try: try:
async with BackgroundSessionLocal() as session: async with BackgroundSessionLocal() as session:
if lyric_id: query_result = await session.execute(
# lyric_id로 특정 레코드 조회 (재생성 시에도 정확한 레코드 업데이트) select(Lyric)
query_result = await session.execute( .where(Lyric.task_id == task_id)
select(Lyric).where(Lyric.id == lyric_id) .order_by(Lyric.created_at.desc())
) .limit(1)
else: )
# 기존 방식: task_id로 최신 레코드 조회
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() lyric = query_result.scalar_one_or_none()
if lyric: if lyric:
@ -58,33 +49,31 @@ async def _update_lyric_status(
if result is not None: if result is not None:
lyric.lyric_result = result lyric.lyric_result = result
await session.commit() 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 return True
else: 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 return False
except SQLAlchemyError as e: 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 return False
except Exception as e: 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 return False
async def generate_lyric_background( async def generate_lyric_background(
task_id: str, task_id: str,
prompt: Prompt, prompt: Prompt,
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
lyric_id: int | None = None,
) -> None: ) -> None:
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다. """백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
Args: Args:
task_id: 프로젝트 task_id task_id: 프로젝트 task_id
prompt: ChatGPT에 전달할 프롬프트 prompt: ChatGPT에 전달할 프롬프트
lyric_input_data: 프롬프트 입력 데이터 language: 가사 언어
lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용)
""" """
import time import time
@ -119,7 +108,7 @@ async def generate_lyric_background(
#result = await service.generate(prompt=prompt) #result = await service.generate(prompt=prompt)
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data) 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 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)") 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() step3_start = time.perf_counter()
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...") 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 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)") 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 2 (GPT API 호출): {step2_elapsed:.1f}ms")
logger.debug(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_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: except SQLAlchemyError as e:
elapsed = (time.perf_counter() - task_start) * 1000 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) 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: except Exception as e:
elapsed = (time.perf_counter() - task_start) * 1000 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) 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)}")

View File

View File

@ -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}")

View File

@ -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": "생성일시",
}

View File

View File

@ -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")>"
)

View File

@ -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

View File

@ -1,15 +0,0 @@
"""
Social Media Integration Module
소셜 미디어 플랫폼 연동 영상 업로드 기능을 제공합니다.
지원 플랫폼:
- YouTube (구현됨)
- Instagram (추후 구현)
- Facebook (추후 구현)
- TikTok (추후 구현)
"""
from app.social.constants import SocialPlatform, UploadStatus
__all__ = ["SocialPlatform", "UploadStatus"]

View File

@ -1,3 +0,0 @@
"""
Social API Module
"""

View File

@ -1,3 +0,0 @@
"""
Social API Routers
"""

View File

@ -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"]

View File

@ -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} 계정 연동이 해제되었습니다.",
)

View File

@ -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

View File

@ -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="업로드가 취소되었습니다.",
)

View File

@ -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",
# ]

View File

@ -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",
)

View File

@ -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")>"
)

View File

@ -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",
]

View File

@ -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

View File

@ -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()

View File

@ -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": "작업이 완료되었습니다.",
}
}
)

View File

@ -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()

View File

@ -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",
]

View File

@ -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 ""

View File

@ -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()

View File

@ -1,9 +0,0 @@
"""
Social Worker Module
소셜 미디어 백그라운드 태스크 모듈입니다.
"""
from app.social.worker.upload_task import process_social_upload
__all__ = ["process_social_upload"]

View File

@ -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}")

View File

@ -9,7 +9,7 @@ Song API Router
사용 예시: 사용 예시:
from app.song.api.routers.v1.song import 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 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.database.session import get_session
from app.home.models import Project from app.home.models import Project
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song, SongTimestamp
from app.song.schemas.song_schema import ( from app.song.schemas.song_schema import (
@ -42,9 +40,6 @@ router = APIRouter(prefix="/song", tags=["Song"])
description=""" description="""
Suno API를 통해 노래 생성을 요청합니다. Suno API를 통해 노래 생성을 요청합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터 ## 경로 파라미터
- **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 사용 - **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 사용
@ -59,16 +54,14 @@ Suno API를 통해 노래 생성을 요청합니다.
- **song_id**: Suno API 작업 ID (상태 조회에 사용) - **song_id**: Suno API 작업 ID (상태 조회에 사용)
- **message**: 응답 메시지 - **message**: 응답 메시지
## 사용 예시 (cURL) ## 사용 예시
```bash ```
curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234567890" \\ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
-H "Authorization: Bearer {access_token}" \\ {
-H "Content-Type: application/json" \\
-d '{
"lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께", "lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께",
"genre": "K-Pop", "genre": "K-Pop",
"language": "Korean" "language": "Korean"
}' }
``` ```
## 참고 ## 참고
@ -79,7 +72,6 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
response_model=GenerateSongResponse, response_model=GenerateSongResponse,
responses={ responses={
200: {"description": "노래 생성 요청 성공"}, 200: {"description": "노래 생성 요청 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "Project 또는 Lyric을 찾을 수 없음"}, 404: {"description": "Project 또는 Lyric을 찾을 수 없음"},
500: {"description": "노래 생성 요청 실패"}, 500: {"description": "노래 생성 요청 실패"},
}, },
@ -87,7 +79,6 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
async def generate_song( async def generate_song(
task_id: str, task_id: str,
request_body: GenerateSongRequest, request_body: GenerateSongRequest,
current_user: User = Depends(get_current_user),
) -> GenerateSongResponse: ) -> GenerateSongResponse:
"""가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다. """가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다.
@ -322,9 +313,6 @@ async def generate_song(
Suno API를 통해 노래 생성 작업의 상태를 조회합니다. Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다. SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터 ## 경로 파라미터
- **song_id**: 노래 생성 반환된 Suno API 작업 ID (필수) - **song_id**: 노래 생성 반환된 Suno API 작업 ID (필수)
@ -333,10 +321,9 @@ SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
- **status**: Suno API 작업 상태 - **status**: Suno API 작업 상태
- **message**: 상태 메시지 - **message**: 상태 메시지
## 사용 예시 (cURL) ## 사용 예시
```bash ```
curl -X GET "http://localhost:8000/song/status/{song_id}" \\ GET /song/status/abc123...
-H "Authorization: Bearer {access_token}"
``` ```
## 상태 값 (Suno API 응답) ## 상태 값 (Suno API 응답)
@ -355,13 +342,11 @@ curl -X GET "http://localhost:8000/song/status/{song_id}" \\
response_model=PollingSongResponse, response_model=PollingSongResponse,
responses={ responses={
200: {"description": "상태 조회 성공"}, 200: {"description": "상태 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
}, },
) )
async def get_song_status( async def get_song_status(
song_id: str, song_id: str,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> PollingSongResponse: ) -> PollingSongResponse:
"""song_id로 노래 생성 작업의 상태를 조회합니다. """song_id로 노래 생성 작업의 상태를 조회합니다.
@ -407,7 +392,7 @@ async def get_song_status(
# song_id로 Song 조회 # song_id로 Song 조회
song_result = await session.execute( song_result = await session.execute(
select(Song) select(Song)
.where(Song.suno_task_id == song_id) .where(Song.suno_task_id == suno_task_id)
.order_by(Song.created_at.desc()) .order_by(Song.created_at.desc())
.limit(1) .limit(1)
) )
@ -415,6 +400,13 @@ async def get_song_status(
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지) # processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
if song and 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으로 변경 (중복 호출 방지) # 상태를 uploading으로 변경 (중복 호출 방지)
song.status = "uploading" song.status = "uploading"
song.suno_audio_id = first_clip.get("id") song.suno_audio_id = first_clip.get("id")
@ -426,13 +418,13 @@ async def get_song_status(
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행 # 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
background_tasks.add_task( background_tasks.add_task(
download_and_upload_song_by_suno_task_id, download_and_upload_song_by_suno_task_id,
suno_task_id=song_id, suno_task_id=suno_task_id,
audio_url=audio_url, audio_url=audio_url,
user_uuid=current_user.user_uuid, store_name=store_name,
duration=clip_duration, duration=clip_duration,
) )
logger.info( 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") suno_audio_id = first_clip.get("id")
@ -475,19 +467,6 @@ async def get_song_status(
for order_idx, timestamped_lyric in enumerate( for order_idx, timestamped_lyric in enumerate(
timestamped_lyrics 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( song_timestamp = SongTimestamp(
suno_audio_id=suno_audio_id, suno_audio_id=suno_audio_id,
order_idx=order_idx, order_idx=order_idx,

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -23,7 +23,7 @@ class Song(Base):
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키) project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키) lyric_id: 연결된 Lyric의 id (외래키)
task_id: 노래 생성 작업의 고유 식별자 (UUID7 형식) task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
suno_task_id: Suno API 작업 고유 식별자 (선택) suno_task_id: Suno API 작업 고유 식별자 (선택)
status: 처리 상태 (processing, uploading, completed, failed) status: 처리 상태 (processing, uploading, completed, failed)
song_prompt: 노래 생성에 사용된 프롬프트 song_prompt: 노래 생성에 사용된 프롬프트
@ -39,10 +39,6 @@ class Song(Base):
__tablename__ = "song" __tablename__ = "song"
__table_args__ = ( __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_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -62,6 +58,7 @@ class Song(Base):
Integer, Integer,
ForeignKey("project.id", ondelete="CASCADE"), ForeignKey("project.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Project의 id", comment="연결된 Project의 id",
) )
@ -69,13 +66,14 @@ class Song(Base):
Integer, Integer,
ForeignKey("lyric.id", ondelete="CASCADE"), ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Lyric의 id", comment="연결된 Lyric의 id",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(36),
nullable=False, nullable=False,
comment="노래 생성 작업 고유 식별자 (UUID7)", comment="노래 생성 작업 고유 식별자 (UUID)",
) )
suno_task_id: Mapped[Optional[str]] = mapped_column( suno_task_id: Mapped[Optional[str]] = mapped_column(
@ -120,13 +118,6 @@ class Song(Base):
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", 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( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
@ -186,8 +177,6 @@ class SongTimestamp(Base):
__tablename__ = "song_timestamp" __tablename__ = "song_timestamp"
__table_args__ = ( __table_args__ = (
Index("idx_song_timestamp_suno_audio_id", "suno_audio_id"),
Index("idx_song_timestamp_is_deleted", "is_deleted"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -206,6 +195,7 @@ class SongTimestamp(Base):
suno_audio_id: Mapped[str] = mapped_column( suno_audio_id: Mapped[str] = mapped_column(
String(64), String(64),
nullable=False, nullable=False,
index=True,
comment="가사의 원본 오디오 ID", comment="가사의 원본 오디오 ID",
) )
@ -233,13 +223,6 @@ class SongTimestamp(Base):
comment="가사 종료 시점 (초)", comment="가사 종료 시점 (초)",
) )
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,

View File

@ -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 from pydantic import BaseModel, Field
@ -104,6 +107,21 @@ class GenerateSongResponse(BaseModel):
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") 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): class SongClipData(BaseModel):
"""생성된 노래 클립 정보""" """생성된 노래 클립 정보"""
@ -216,3 +234,94 @@ class PollingSongResponse(BaseModel):
song_result_url: Optional[str] = Field( song_result_url: Optional[str] = Field(
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)" 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", ""),
)

View File

@ -4,6 +4,8 @@ Song Background Tasks
노래 생성 관련 백그라운드 태스크를 정의합니다. 노래 생성 관련 백그라운드 태스크를 정의합니다.
""" """
import traceback
from datetime import date
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
@ -13,8 +15,10 @@ from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.song.models import Song from app.song.models import Song
from app.utils.common import generate_task_id
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
from config import prj_settings
# 로거 설정 # 로거 설정
logger = get_logger("song") logger = get_logger("song")
@ -29,7 +33,6 @@ async def _update_song_status(
song_url: str | None = None, song_url: str | None = None,
suno_task_id: str | None = None, suno_task_id: str | None = None,
duration: float | None = None, duration: float | None = None,
song_id: int | None = None,
) -> bool: ) -> bool:
"""Song 테이블의 상태를 업데이트합니다. """Song 테이블의 상태를 업데이트합니다.
@ -39,20 +42,13 @@ async def _update_song_status(
song_url: 노래 URL song_url: 노래 URL
suno_task_id: Suno task ID (선택) suno_task_id: Suno task ID (선택)
duration: 노래 길이 (선택) duration: 노래 길이 (선택)
song_id: 특정 Song 레코드 ID (재생성 정확한 레코드 식별용)
Returns: Returns:
bool: 업데이트 성공 여부 bool: 업데이트 성공 여부
""" """
try: try:
async with BackgroundSessionLocal() as session: async with BackgroundSessionLocal() as session:
if song_id: if suno_task_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)
query_result = await session.execute( query_result = await session.execute(
select(Song) select(Song)
.where(Song.suno_task_id == suno_task_id) .where(Song.suno_task_id == suno_task_id)
@ -60,7 +56,6 @@ async def _update_song_status(
.limit(1) .limit(1)
) )
else: else:
# 기존 방식: task_id로 최신 레코드 조회 (비권장)
query_result = await session.execute( query_result = await session.execute(
select(Song) select(Song)
.where(Song.task_id == task_id) .where(Song.task_id == task_id)
@ -77,17 +72,17 @@ async def _update_song_status(
if duration is not None: if duration is not None:
song.duration = duration song.duration = duration
await session.commit() 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 return True
else: 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 return False
except SQLAlchemyError as e: 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 return False
except Exception as e: 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 return False
@ -114,23 +109,85 @@ async def _download_audio(url: str, task_id: str) -> bytes:
return response.content 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( async def download_and_upload_song_by_suno_task_id(
suno_task_id: str, suno_task_id: str,
audio_url: str, audio_url: str,
user_uuid: str, store_name: str,
duration: float | None = None, duration: float | None = None,
) -> None: ) -> None:
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. """suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
파일명은 suno_task_id를 사용하여 고유성을 보장합니다.
Args: Args:
suno_task_id: Suno API 작업 ID (파일명으로도 사용) suno_task_id: Suno API 작업 ID
audio_url: 다운로드할 오디오 URL audio_url: 다운로드할 오디오 URL
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) store_name: 저장할 파일명에 사용할 업체명
duration: 노래 재생 시간 () 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 temp_file_path: Path | None = None
task_id: str | 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 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}") 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 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}") 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에 업로드 # 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)) upload_success = await uploader.upload_music(file_path=str(temp_file_path))
if not upload_success: if not upload_success:

View File

@ -5,15 +5,10 @@
""" """
import logging import logging
import random
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi import APIRouter, Depends, Header, Request, status
from app.utils.timezone import now
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import prj_settings from config import prj_settings
@ -21,68 +16,19 @@ from app.database.session import get_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from app.user.dependencies import get_current_user 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 ( from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCodeRequest, KakaoCodeRequest,
KakaoLoginResponse, KakaoLoginResponse,
LoginResponse, LoginResponse,
RefreshTokenRequest, RefreshTokenRequest,
TokenResponse,
UserResponse, UserResponse,
) )
from app.user.services import auth_service, kakao_client 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"]) 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( @router.get(
"/kakao/login", "/kakao/login",
@ -143,19 +89,6 @@ async def kakao_callback(
ip_address=ip_address, 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 = ( redirect_url = (
f"{prj_settings.PROJECT_DOMAIN}" f"{prj_settings.PROJECT_DOMAIN}"
@ -221,49 +154,32 @@ async def kakao_verify(
ip_address=ip_address, ip_address=ip_address,
) )
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신 logger.info(
try: f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}"
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}")
return result return result
@router.post( @router.post(
"/refresh", "/refresh",
response_model=TokenResponse, response_model=AccessTokenResponse,
summary="토큰 갱신 (Refresh Token Rotation)", summary="토큰 갱신",
description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.", description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
) )
async def refresh_token( async def refresh_token(
body: RefreshTokenRequest, body: RefreshTokenRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> TokenResponse: ) -> AccessTokenResponse:
""" """
토큰 갱신 (Refresh Token Rotation) 액세스 토큰 갱신
유효한 리프레시 토큰을 제출하면 액세스 토큰 리프레시 토큰발급합니다. 유효한 리프레시 토큰을 제출하면 액세스 토큰발급합니다.
사용된 기존 리프레시 토큰은 즉시 폐기(revoke)니다. 리프레시 토큰은 변경되지 않습니다.
""" """
logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}") return await auth_service.refresh_tokens(
result = await auth_service.refresh_tokens(
refresh_token=body.refresh_token, refresh_token=body.refresh_token,
session=session, 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( @router.post(
@ -271,10 +187,6 @@ async def refresh_token(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="로그아웃", summary="로그아웃",
description="현재 세션의 리프레시 토큰을 폐기합니다.", description="현재 세션의 리프레시 토큰을 폐기합니다.",
responses={
204: {"description": "로그아웃 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
) )
async def logout( async def logout(
body: RefreshTokenRequest, 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( await auth_service.logout(
user_id=current_user.id, user_id=current_user.id,
refresh_token=body.refresh_token, refresh_token=body.refresh_token,
session=session, session=session,
) )
logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}")
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -305,10 +212,6 @@ async def logout(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="모든 기기에서 로그아웃", summary="모든 기기에서 로그아웃",
description="사용자의 모든 리프레시 토큰을 폐기합니다.", description="사용자의 모든 리프레시 토큰을 폐기합니다.",
responses={
204: {"description": "모든 기기에서 로그아웃 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
) )
async def logout_all( async def logout_all(
current_user: User = Depends(get_current_user), 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( await auth_service.logout_all(
user_id=current_user.id, user_id=current_user.id,
session=session, session=session,
) )
logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}")
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -337,10 +235,6 @@ async def logout_all(
response_model=UserResponse, response_model=UserResponse,
summary="내 정보 조회", summary="내 정보 조회",
description="현재 로그인한 사용자의 정보를 반환합니다.", description="현재 로그인한 사용자의 정보를 반환합니다.",
responses={
200: {"description": "조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
) )
async def get_me( async def get_me(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@ -351,143 +245,3 @@ async def get_me(
현재 로그인한 사용자의 상세 정보를 반환합니다. 현재 로그인한 사용자의 상세 정보를 반환합니다.
""" """
return UserResponse.model_validate(current_user) 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(),
)

View File

@ -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="소셜 계정 삭제 중 오류가 발생했습니다.",
)

View File

@ -45,7 +45,7 @@ class UserAdmin(ModelView, model=User):
form_excluded_columns = [ form_excluded_columns = [
"created_at", "created_at",
"updated_at", "updated_at",
"projects", "user_projects",
"refresh_tokens", "refresh_tokens",
"social_accounts", "social_accounts",
] ]
@ -160,17 +160,16 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
column_list = [ column_list = [
"id", "id",
"user_uuid", "user_id",
"platform", "platform",
"platform_username", "platform_username",
"is_active", "is_active",
"is_deleted", "connected_at",
"created_at",
] ]
column_details_list = [ column_details_list = [
"id", "id",
"user_uuid", "user_id",
"platform", "platform",
"platform_user_id", "platform_user_id",
"platform_username", "platform_username",
@ -178,34 +177,32 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
"scope", "scope",
"token_expires_at", "token_expires_at",
"is_active", "is_active",
"is_deleted", "connected_at",
"created_at",
"updated_at", "updated_at",
] ]
form_excluded_columns = ["created_at", "updated_at", "user"] form_excluded_columns = ["connected_at", "updated_at", "user"]
column_searchable_list = [ column_searchable_list = [
SocialAccount.user_uuid, SocialAccount.user_id,
SocialAccount.platform, SocialAccount.platform,
SocialAccount.platform_user_id, SocialAccount.platform_user_id,
SocialAccount.platform_username, SocialAccount.platform_username,
] ]
column_default_sort = (SocialAccount.created_at, True) column_default_sort = (SocialAccount.connected_at, True)
column_sortable_list = [ column_sortable_list = [
SocialAccount.id, SocialAccount.id,
SocialAccount.user_uuid, SocialAccount.user_id,
SocialAccount.platform, SocialAccount.platform,
SocialAccount.is_active, SocialAccount.is_active,
SocialAccount.is_deleted, SocialAccount.connected_at,
SocialAccount.created_at,
] ]
column_labels = { column_labels = {
"id": "ID", "id": "ID",
"user_uuid": "사용자 UUID", "user_id": "사용자 ID",
"platform": "플랫폼", "platform": "플랫폼",
"platform_user_id": "플랫폼 사용자 ID", "platform_user_id": "플랫폼 사용자 ID",
"platform_username": "플랫폼 사용자명", "platform_username": "플랫폼 사용자명",
@ -213,7 +210,6 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
"scope": "권한 범위", "scope": "권한 범위",
"token_expires_at": "토큰 만료일시", "token_expires_at": "토큰 만료일시",
"is_active": "활성화", "is_active": "활성화",
"is_deleted": "삭제됨", "connected_at": "연동일시",
"created_at": "생성일시",
"updated_at": "수정일시", "updated_at": "수정일시",
} }

View File

@ -4,7 +4,6 @@
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다. FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
""" """
import logging
from typing import Optional from typing import Optional
from fastapi import Depends from fastapi import Depends
@ -13,18 +12,17 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
from app.user.models import User from app.user.exceptions import (
from app.user.services.auth import (
AdminRequiredError, AdminRequiredError,
InvalidTokenError, InvalidTokenError,
MissingTokenError, MissingTokenError,
TokenExpiredError,
UserInactiveError, UserInactiveError,
UserNotFoundError, UserNotFoundError,
) )
from app.user.models import User
from app.user.services.jwt import decode_token from app.user.services.jwt import decode_token
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
@ -50,52 +48,35 @@ async def get_current_user(
UserInactiveError: 비활성화된 계정인 경우 UserInactiveError: 비활성화된 계정인 경우
""" """
if credentials is None: if credentials is None:
logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError")
raise MissingTokenError() raise MissingTokenError()
token = credentials.credentials payload = decode_token(credentials.credentials)
logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}")
payload = decode_token(token)
if payload is None: if payload is None:
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
raise InvalidTokenError() raise InvalidTokenError()
# 토큰 타입 확인 # 토큰 타입 확인
if payload.get("type") != "access": if payload.get("type") != "access":
logger.warning(
f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, "
f"got: {payload.get('type')}, sub: {payload.get('sub')}"
)
raise InvalidTokenError("액세스 토큰이 아닙니다.") raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_uuid = payload.get("sub") user_id = payload.get("sub")
if user_uuid is None: if user_id is None:
logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}")
raise InvalidTokenError() raise InvalidTokenError()
# 사용자 조회 # 사용자 조회
result = await session.execute( result = await session.execute(
select(User).where( select(User).where(
User.user_uuid == user_uuid, User.id == int(user_id),
User.is_deleted == False, # noqa: E712 User.is_deleted == False, # noqa: E712
) )
) )
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}")
raise UserNotFoundError() raise UserNotFoundError()
if not user.is_active: if not user.is_active:
logger.warning(
f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}"
)
raise UserInactiveError() raise UserInactiveError()
logger.debug(
f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
)
return user return user
@ -116,43 +97,30 @@ async def get_current_user_optional(
User | None: 로그인한 사용자 또는 None User | None: 로그인한 사용자 또는 None
""" """
if credentials is None: if credentials is None:
logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음")
return None return None
token = credentials.credentials payload = decode_token(credentials.credentials)
payload = decode_token(token)
if payload is None: if payload is None:
logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}")
return None return None
if payload.get("type") != "access": if payload.get("type") != "access":
logger.debug(
f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})"
)
return None return None
user_uuid = payload.get("sub") user_id = payload.get("sub")
if user_uuid is None: if user_id is None:
logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음")
return None return None
result = await session.execute( result = await session.execute(
select(User).where( select(User).where(
User.user_uuid == user_uuid, User.id == int(user_id),
User.is_deleted == False, # noqa: E712 User.is_deleted == False, # noqa: E712
) )
) )
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None or not user.is_active: if user is None or not user.is_active:
logger.debug(
f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}"
)
return None return None
logger.debug(
f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
)
return user return user

141
app/user/exceptions.py Normal file
View File

@ -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,
)

View File

@ -5,7 +5,6 @@ User 모듈 SQLAlchemy 모델 정의
""" """
from datetime import date, datetime from datetime import date, datetime
from enum import Enum
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, func 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 from app.database.session import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from app.home.models import Project from app.home.models import UserProject
class User(Base): class User(Base):
@ -28,7 +26,6 @@ class User(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
kakao_id: 카카오 고유 ID (필수, 유니크) kakao_id: 카카오 고유 ID (필수, 유니크)
user_uuid: 사용자 식별을 위한 UUID7 (필수, 유니크)
email: 이메일 주소 (선택, 카카오에서 제공 ) email: 이메일 주소 (선택, 카카오에서 제공 )
nickname: 카카오 닉네임 (선택) nickname: 카카오 닉네임 (선택)
profile_image_url: 카카오 프로필 이미지 URL (선택) profile_image_url: 카카오 프로필 이미지 URL (선택)
@ -56,7 +53,8 @@ class User(Base):
- 실제 데이터는 DB에 유지됨 - 실제 데이터는 DB에 유지됨
Relationships: Relationships:
projects: 사용자가 소유한 프로젝트 목록 (1:N 관계) user_projects: Project와의 M:N 관계 (중계 테이블 통한 연결)
projects: 사용자가 참여한 프로젝트 목록 (Association Proxy)
카카오 API 응답 필드 매핑: 카카오 API 응답 필드 매핑:
- kakao_id: id (카카오 회원번호) - kakao_id: id (카카오 회원번호)
@ -70,8 +68,7 @@ class User(Base):
__tablename__ = "user" __tablename__ = "user"
__table_args__ = ( __table_args__ = (
Index("idx_user_kakao_id", "kakao_id"), Index("idx_user_kakao_id", "kakao_id", unique=True),
Index("idx_user_uuid", "user_uuid"),
Index("idx_user_email", "email"), Index("idx_user_email", "email"),
Index("idx_user_phone", "phone"), Index("idx_user_phone", "phone"),
Index("idx_user_is_active", "is_active"), Index("idx_user_is_active", "is_active"),
@ -106,13 +103,6 @@ class User(Base):
comment="카카오 고유 ID (회원번호)", 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와 양방향 연결 # back_populates: UserProject.user와 양방향 연결
# cascade: 사용자 삭제 시 프로젝트는 유지 (owner가 NULL로 설정됨) # cascade: User 삭제 시 UserProject 레코드도 삭제 (Project는 유지)
# lazy="selectin": N+1 문제 방지 # lazy="selectin": N+1 문제 방지
# ========================================================================== # ==========================================================================
projects: Mapped[List["Project"]] = relationship( user_projects: Mapped[List["UserProject"]] = relationship(
"Project", "UserProject",
back_populates="owner", back_populates="user",
cascade="all, delete-orphan",
lazy="selectin", lazy="selectin",
) )
@ -291,7 +282,6 @@ class RefreshToken(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조) user_id: 사용자 외래키 (User.id 참조)
user_uuid: 사용자 UUID (User.user_uuid 참조)
token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X) token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X)
expires_at: 토큰 만료 일시 expires_at: 토큰 만료 일시
is_revoked: 토큰 폐기 여부 (로그아웃 True) is_revoked: 토큰 폐기 여부 (로그아웃 True)
@ -309,7 +299,6 @@ class RefreshToken(Base):
__tablename__ = "refresh_token" __tablename__ = "refresh_token"
__table_args__ = ( __table_args__ = (
Index("idx_refresh_token_user_id", "user_id"), 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_token_hash", "token_hash", unique=True),
Index("idx_refresh_token_expires_at", "expires_at"), Index("idx_refresh_token_expires_at", "expires_at"),
Index("idx_refresh_token_is_revoked", "is_revoked"), Index("idx_refresh_token_is_revoked", "is_revoked"),
@ -335,15 +324,10 @@ class RefreshToken(Base):
comment="사용자 외래키 (User.id 참조)", comment="사용자 외래키 (User.id 참조)",
) )
user_uuid: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="사용자 UUID (User.user_uuid 참조)",
)
token_hash: Mapped[str] = mapped_column( token_hash: Mapped[str] = mapped_column(
String(64), String(64),
nullable=False, nullable=False,
unique=True,
comment="리프레시 토큰 SHA-256 해시값", comment="리프레시 토큰 SHA-256 해시값",
) )
@ -391,7 +375,6 @@ class RefreshToken(Base):
user: Mapped["User"] = relationship( user: Mapped["User"] = relationship(
"User", "User",
back_populates="refresh_tokens", back_populates="refresh_tokens",
lazy="selectin", # lazy loading 방지
) )
def __repr__(self) -> str: 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): class SocialAccount(Base):
""" """
소셜 계정 연동 테이블 소셜 계정 연동 테이블
@ -447,13 +421,12 @@ class SocialAccount(Base):
__tablename__ = "social_account" __tablename__ = "social_account"
__table_args__ = ( __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_platform", "platform"),
Index("idx_social_account_is_active", "is_active"), Index("idx_social_account_is_active", "is_active"),
Index("idx_social_account_is_deleted", "is_deleted"),
Index( Index(
"uq_user_platform_account", "uq_user_platform_account",
"user_uuid", "user_id",
"platform", "platform",
"platform_user_id", "platform_user_id",
unique=True, unique=True,
@ -476,20 +449,20 @@ class SocialAccount(Base):
comment="고유 식별자", comment="고유 식별자",
) )
user_uuid: Mapped[str] = mapped_column( user_id: Mapped[int] = mapped_column(
String(36), BigInteger,
ForeignKey("user.user_uuid", ondelete="CASCADE"), ForeignKey("user.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="사용자 외래키 (User.user_uuid 참조)", comment="사용자 외래키 (User.id 참조)",
) )
# ========================================================================== # ==========================================================================
# 플랫폼 구분 # 플랫폼 구분
# ========================================================================== # ==========================================================================
platform: Mapped[Platform] = mapped_column( platform: Mapped[str] = mapped_column(
String(20), String(20),
nullable=False, 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), String(100),
nullable=True, nullable=False,
comment="플랫폼 내 사용자 고유 ID", comment="플랫폼 내 사용자 고유 ID",
) )
@ -550,14 +523,7 @@ class SocialAccount(Base):
Boolean, Boolean,
nullable=False, nullable=False,
default=True, default=True,
comment="활성화 상태 (비활성화 시 사용 중지)", comment="연동 활성화 상태 (비활성화 시 사용 중지)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
) )
# ========================================================================== # ==========================================================================
@ -565,12 +531,11 @@ class SocialAccount(Base):
# ========================================================================== # ==========================================================================
connected_at: Mapped[datetime] = mapped_column( connected_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=True, nullable=False,
server_default=func.now(), server_default=func.now(),
onupdate=func.now(), comment="연동 일시",
comment="연결 일시",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
@ -579,27 +544,19 @@ class SocialAccount(Base):
comment="정보 수정 일시", comment="정보 수정 일시",
) )
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
# ========================================================================== # ==========================================================================
# User 관계 # User 관계
# ========================================================================== # ==========================================================================
user: Mapped["User"] = relationship( user: Mapped["User"] = relationship(
"User", "User",
back_populates="social_accounts", back_populates="social_accounts",
lazy="selectin", # lazy loading 방지
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<SocialAccount(" f"<SocialAccount("
f"id={self.id}, " f"id={self.id}, "
f"user_uuid='{self.user_uuid}', " f"user_id={self.user_id}, "
f"platform='{self.platform}', " f"platform='{self.platform}', "
f"platform_username='{self.platform_username}', " f"platform_username='{self.platform_username}', "
f"is_active={self.is_active}" f"is_active={self.is_active}"

View File

@ -1,4 +1,5 @@
from app.user.schemas.user_schema import ( from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCodeRequest, KakaoCodeRequest,
KakaoLoginResponse, KakaoLoginResponse,
KakaoTokenResponse, KakaoTokenResponse,
@ -11,6 +12,7 @@ from app.user.schemas.user_schema import (
) )
__all__ = [ __all__ = [
"AccessTokenResponse",
"KakaoCodeRequest", "KakaoCodeRequest",
"KakaoLoginResponse", "KakaoLoginResponse",
"KakaoTokenResponse", "KakaoTokenResponse",

View File

@ -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
}
}
}

View File

@ -21,7 +21,7 @@ class KakaoLoginResponse(BaseModel):
model_config = { model_config = {
"json_schema_extra": { "json_schema_extra": {
"example": { "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): class RefreshTokenRequest(BaseModel):
"""토큰 갱신 요청""" """토큰 갱신 요청"""
@ -138,13 +156,13 @@ class UserBriefResponse(BaseModel):
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
"""로그인 응답 (토큰 정보)""" """로그인 응답 (토큰 + 사용자 정보)"""
access_token: str = Field(..., description="액세스 토큰") access_token: str = Field(..., description="액세스 토큰")
refresh_token: str = Field(..., description="리프레시 토큰") refresh_token: str = Field(..., description="리프레시 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입") token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)") expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
is_new_user: bool = Field(..., description="신규 가입 여부") user: UserBriefResponse = Field(..., description="사용자 정보")
redirect_url: str = Field(..., description="로그인 후 리다이렉트할 프론트엔드 URL") redirect_url: str = Field(..., description="로그인 후 리다이렉트할 프론트엔드 URL")
model_config = { model_config = {
@ -154,7 +172,13 @@ class LoginResponse(BaseModel):
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 3600, "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" "redirect_url": "http://localhost:3000"
} }
} }

View File

@ -5,85 +5,29 @@
""" """
import logging import logging
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import HTTPException, status
from app.utils.timezone import now
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import prj_settings from config import prj_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from app.user.exceptions import (
# ============================================================================= InvalidTokenError,
# 인증 예외 클래스 정의 TokenExpiredError,
# ============================================================================= TokenRevokedError,
class AuthException(HTTPException): UserInactiveError,
"""인증 관련 기본 예외""" UserNotFoundError,
)
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.models import RefreshToken, User from app.user.models import RefreshToken, User
from app.utils.common import generate_uuid
from app.user.schemas.user_schema import ( from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo, KakaoUserInfo,
LoginResponse, LoginResponse,
TokenResponse, UserBriefResponse,
) )
from app.user.services.jwt import ( from app.user.services.jwt import (
create_access_token, create_access_token,
@ -151,28 +95,27 @@ class AuthService:
# 5. JWT 토큰 생성 # 5. JWT 토큰 생성
logger.info("[AUTH] 5단계: JWT 토큰 생성 시작") logger.info("[AUTH] 5단계: JWT 토큰 생성 시작")
access_token = create_access_token(user.user_uuid) access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.user_uuid) refresh_token = create_refresh_token(user.id)
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_uuid: {user.user_uuid}") logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_id: {user.id}")
# 6. 리프레시 토큰 DB 저장 # 6. 리프레시 토큰 DB 저장
logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작") logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작")
await self._save_refresh_token( await self._save_refresh_token(
user_id=user.id, user_id=user.id,
user_uuid=user.user_uuid,
token=refresh_token, token=refresh_token,
session=session, session=session,
user_agent=user_agent, user_agent=user_agent,
ip_address=ip_address, 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. 마지막 로그인 시간 업데이트 # 7. 마지막 로그인 시간 업데이트
user.last_login_at = now().replace(tzinfo=None) user.last_login_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}" 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]}...") logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
return LoginResponse( return LoginResponse(
@ -180,7 +123,13 @@ class AuthService:
refresh_token=refresh_token, refresh_token=refresh_token,
token_type="Bearer", token_type="Bearer",
expires_in=get_access_token_expire_seconds(), 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, redirect_url=redirect_url,
) )
@ -188,129 +137,59 @@ class AuthService:
self, self,
refresh_token: str, refresh_token: str,
session: AsyncSession, session: AsyncSession,
) -> TokenResponse: ) -> AccessTokenResponse:
""" """
리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation) 리프레시 토큰으로 액세스 토큰 갱신
기존 리프레시 토큰을 폐기하고, 액세스 토큰과 리프레시 토큰을 함께 발급합니다.
사용자가 서비스를 지속 사용하는 세션이 자동 유지됩니다.
Args: Args:
refresh_token: 리프레시 토큰 refresh_token: 리프레시 토큰
session: DB 세션 session: DB 세션
Returns: Returns:
TokenResponse: 액세스 토큰 + 리프레시 토큰 AccessTokenResponse: 액세스 토큰
Raises: Raises:
InvalidTokenError: 토큰이 유효하지 않은 경우 InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우 TokenExpiredError: 토큰이 만료된 경우
TokenRevokedError: 토큰이 폐기된 경우 TokenRevokedError: 토큰이 폐기된 경우
""" """
logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}")
# 1. 토큰 디코딩 및 검증 # 1. 토큰 디코딩 및 검증
payload = decode_token(refresh_token) payload = decode_token(refresh_token)
if payload is None: if payload is None:
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
raise InvalidTokenError() raise InvalidTokenError()
if payload.get("type") != "refresh": if payload.get("type") != "refresh":
logger.warning(
f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, "
f"sub: {payload.get('sub')}"
)
raise InvalidTokenError("리프레시 토큰이 아닙니다.") raise InvalidTokenError("리프레시 토큰이 아닙니다.")
logger.debug(
f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, "
f"exp: {payload.get('exp')}"
)
# 2. DB에서 리프레시 토큰 조회 # 2. DB에서 리프레시 토큰 조회
token_hash = get_token_hash(refresh_token) token_hash = get_token_hash(refresh_token)
db_token = await self._get_refresh_token_by_hash(token_hash, session) db_token = await self._get_refresh_token_by_hash(token_hash, session)
if db_token is None: if db_token is None:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, "
f"token_hash: {token_hash[:16]}..."
)
raise InvalidTokenError() 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. 토큰 상태 확인 # 3. 토큰 상태 확인
if db_token.is_revoked: 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() raise TokenRevokedError()
# 4. 만료 확인 if db_token.expires_at < datetime.now(timezone.utc):
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}"
)
raise TokenExpiredError() raise TokenExpiredError()
# 5. 사용자 확인 # 4. 사용자 확인
user_uuid = payload.get("sub") user_id = int(payload.get("sub"))
user = await self._get_user_by_uuid(user_uuid, session) user = await self._get_user_by_id(user_id, session)
if user is None: if user is None:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}"
)
raise UserNotFoundError() raise UserNotFoundError()
if not user.is_active: if not user.is_active:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, "
f"user_id: {user.id}"
)
raise UserInactiveError() raise UserInactiveError()
# 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음) # 5. 새 액세스 토큰 발급
db_token.is_revoked = True new_access_token = create_access_token(user.id)
db_token.revoked_at = now().replace(tzinfo=None)
logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...")
# 7. 새 토큰 발급 return AccessTokenResponse(
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(
access_token=new_access_token, access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="Bearer", token_type="Bearer",
expires_in=get_access_token_expire_seconds(), expires_in=get_access_token_expire_seconds(),
) )
@ -330,12 +209,7 @@ class AuthService:
session: DB 세션 session: DB 세션
""" """
token_hash = get_token_hash(refresh_token) 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) await self._revoke_refresh_token_by_hash(token_hash, session)
logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}")
async def logout_all( async def logout_all(
self, self,
@ -349,9 +223,7 @@ class AuthService:
user_id: 사용자 ID user_id: 사용자 ID
session: DB 세션 session: DB 세션
""" """
logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}")
await self._revoke_all_user_tokens(user_id, session) await self._revoke_all_user_tokens(user_id, session)
logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}")
async def _get_or_create_user( async def _get_or_create_user(
self, self,
@ -396,59 +268,22 @@ class AuthService:
# 신규 사용자 생성 # 신규 사용자 생성
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}") logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
user_uuid = await generate_uuid(session=session, table_name=User)
new_user = User( new_user = User(
kakao_id=kakao_id, kakao_id=kakao_id,
user_uuid=user_uuid,
email=kakao_account.email if kakao_account else None, email=kakao_account.email if kakao_account else None,
nickname=profile.nickname if profile else None, nickname=profile.nickname if profile else None,
profile_image_url=profile.profile_image_url 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, thumbnail_image_url=profile.thumbnail_image_url if profile else None,
) )
session.add(new_user) session.add(new_user)
await session.flush()
try: await session.refresh(new_user)
await session.flush() logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
await session.refresh(new_user) return new_user, True
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
async def _save_refresh_token( async def _save_refresh_token(
self, self,
user_id: int, user_id: int,
user_uuid: str,
token: str, token: str,
session: AsyncSession, session: AsyncSession,
user_agent: Optional[str] = None, user_agent: Optional[str] = None,
@ -459,7 +294,6 @@ class AuthService:
Args: Args:
user_id: 사용자 ID user_id: 사용자 ID
user_uuid: 사용자 UUID
token: 리프레시 토큰 token: 리프레시 토큰
session: DB 세션 session: DB 세션
user_agent: User-Agent user_agent: User-Agent
@ -473,7 +307,6 @@ class AuthService:
refresh_token = RefreshToken( refresh_token = RefreshToken(
user_id=user_id, user_id=user_id,
user_uuid=user_uuid,
token_hash=token_hash, token_hash=token_hash,
expires_at=expires_at, expires_at=expires_at,
user_agent=user_agent, user_agent=user_agent,
@ -481,11 +314,6 @@ class AuthService:
) )
session.add(refresh_token) session.add(refresh_token)
await session.flush() 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 return refresh_token
async def _get_refresh_token_by_hash( async def _get_refresh_token_by_hash(
@ -528,26 +356,6 @@ class AuthService:
) )
return result.scalar_one_or_none() 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( async def _revoke_refresh_token_by_hash(
self, self,
token_hash: str, token_hash: str,
@ -565,7 +373,7 @@ class AuthService:
.where(RefreshToken.token_hash == token_hash) .where(RefreshToken.token_hash == token_hash)
.values( .values(
is_revoked=True, is_revoked=True,
revoked_at=now().replace(tzinfo=None), revoked_at=datetime.now(timezone.utc),
) )
) )
await session.commit() await session.commit()
@ -590,7 +398,7 @@ class AuthService:
) )
.values( .values(
is_revoked=True, is_revoked=True,
revoked_at=now().replace(tzinfo=None), revoked_at=datetime.now(timezone.utc),
) )
) )
await session.commit() await session.commit()

View File

@ -5,77 +5,62 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니
""" """
import hashlib import hashlib
import logging from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
from app.utils.timezone import now
from config import jwt_settings from config import jwt_settings
logger = logging.getLogger(__name__)
def create_access_token(user_id: int) -> str:
def create_access_token(user_uuid: str) -> str:
""" """
JWT 액세스 토큰 생성 JWT 액세스 토큰 생성
Args: Args:
user_uuid: 사용자 UUID user_id: 사용자 ID
Returns: Returns:
JWT 액세스 토큰 문자열 JWT 액세스 토큰 문자열
""" """
expire = now() + timedelta( expire = datetime.now(timezone.utc) + timedelta(
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
) )
to_encode = { to_encode = {
"sub": user_uuid, "sub": str(user_id),
"exp": expire, "exp": expire,
"type": "access", "type": "access",
} }
token = jwt.encode( return jwt.encode(
to_encode, to_encode,
jwt_settings.JWT_SECRET, jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM, 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 리프레시 토큰 생성 JWT 리프레시 토큰 생성
Args: Args:
user_uuid: 사용자 UUID user_id: 사용자 ID
Returns: Returns:
JWT 리프레시 토큰 문자열 JWT 리프레시 토큰 문자열
""" """
expire = now() + timedelta( expire = datetime.now(timezone.utc) + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )
to_encode = { to_encode = {
"sub": user_uuid, "sub": str(user_id),
"exp": expire, "exp": expire,
"type": "refresh", "type": "refresh",
} }
token = jwt.encode( return jwt.encode(
to_encode, to_encode,
jwt_settings.JWT_SECRET, jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM, 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]: def decode_token(token: str) -> Optional[dict]:
@ -94,25 +79,8 @@ def decode_token(token: str) -> Optional[dict]:
jwt_settings.JWT_SECRET, jwt_settings.JWT_SECRET,
algorithms=[jwt_settings.JWT_ALGORITHM], 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 return payload
except ExpiredSignatureError: except JWTError:
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:]}"
)
return None return None
@ -136,9 +104,9 @@ def get_refresh_token_expires_at() -> datetime:
리프레시 토큰 만료 시간 계산 리프레시 토큰 만료 시간 계산
Returns: Returns:
리프레시 토큰 만료 datetime (로컬 시간) 리프레시 토큰 만료 datetime (UTC)
""" """
return now().replace(tzinfo=None) + timedelta( return datetime.now(timezone.utc) + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )

View File

@ -7,39 +7,15 @@
import logging import logging
import aiohttp import aiohttp
from fastapi import HTTPException, status
from config import kakao_settings from config import kakao_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo 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: class KakaoOAuthClient:
""" """
카카오 OAuth API 클라이언트 카카오 OAuth API 클라이언트

View File

@ -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)

View File

@ -1,94 +1,47 @@
import json import json
import re import re
from pydantic import BaseModel
from openai import AsyncOpenAI from openai import AsyncOpenAI
from app.utils.logger import get_logger 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 from app.utils.prompts.prompts import Prompt
# 로거 설정 # 로거 설정
logger = get_logger("chatgpt") logger = get_logger("chatgpt")
# fmt: on
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}")
class ChatgptService: class ChatgptService:
"""ChatGPT API 서비스 클래스 """ChatGPT API 서비스 클래스
GPT 5.0 모델을 사용하여 마케팅 가사 분석을 생성합니다.
""" """
def __init__(self, timeout: float = None): def __init__(self):
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
self.client = AsyncOpenAI( async def _call_structured_output_with_response_gpt_api(self, prompt: str, output_format : dict, model:str) -> dict:
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 자체임에 유의할 것
content = [{"type": "input_text", "text": prompt}] content = [{"type": "input_text", "text": prompt}]
last_error = None response = await self.client.responses.create(
for attempt in range(self.max_retries + 1): model=model,
response = await self.client.responses.parse( input=[{"role": "user", "content": content}],
model=model, text = output_format
input=[{"role": "user", "content": content}], )
text_format=output_format structured_output = json.loads(response.output_text)
) return structured_output or {}
# 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}")
# 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( async def generate_structured_output(
self, self,
prompt : Prompt, prompt : Prompt,
input_data : dict, input_data : dict,
) -> BaseModel: ) -> str:
prompt_text = prompt.build_prompt(input_data) prompt_text = prompt.build_prompt(input_data)
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})") 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}") logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
# GPT API 호출 # GPT API 호출
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model) 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)
return response return response

View File

@ -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 생성
task_id = await generate_task_id(session=session, table_name=Project) task_id = await generate_task_id(session=session, table_name=Project)
# uuid 생성
user_uuid = await generate_uuid(session=session, table_name=User)
Note: Note:
페이지네이션 기능은 app.utils.pagination 모듈을 사용하세요: 페이지네이션 기능은 app.utils.pagination 모듈을 사용하세요:
from app.utils.pagination import PaginatedResponse, get_paginated from app.utils.pagination import PaginatedResponse, get_paginated
""" """
import os
import time
from typing import Any, Optional, Type from typing import Any, Optional, Type
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from uuid_extensions import uuid7
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]}"
async def generate_task_id( async def generate_task_id(
@ -82,16 +32,16 @@ async def generate_task_id(
table_name: task_id 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional) table_name: task_id 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
Returns: Returns:
str: 생성된 UUID7 문자열 (36) str: 생성된 uuid7 문자열
Usage: Usage:
# 단순 UUID7 생성 # 단순 uuid7 생성
task_id = await generate_task_id() task_id = await generate_task_id()
# 테이블에서 중복 검사 후 생성 # 테이블에서 중복 검사 후 생성
task_id = await generate_task_id(session=session, table_name=Project) 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: if session is None or table_name is None:
return task_id return task_id
@ -105,41 +55,4 @@ async def generate_task_id(
if existing is None: if existing is None:
return task_id return task_id
task_id = _generate_uuid7_string() task_id = str(uuid7())
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()

View File

@ -36,29 +36,12 @@ from typing import Literal
import httpx import httpx
from app.utils.logger import get_logger 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") 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 타입 정의 # Orientation 타입 정의
OrientationType = Literal["horizontal", "vertical"] 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 = { text_template_h_1 = {
"type": "composition", "type": "composition",
"track": 3, "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: async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_client global _shared_client
if _shared_client is None or _shared_client.is_closed: if _shared_client is None or _shared_client.is_closed:
_shared_client = httpx.AsyncClient( _shared_client = httpx.AsyncClient(
timeout=httpx.Timeout( timeout=httpx.Timeout(60.0, connect=10.0),
recovery_settings.CREATOMATE_RENDER_TIMEOUT,
connect=recovery_settings.CREATOMATE_CONNECT_TIMEOUT,
),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
) )
return _shared_client return _shared_client
@ -311,7 +217,7 @@ class CreatomateService:
self, self,
method: str, method: str,
url: str, url: str,
timeout: float | None = None, timeout: float = 30.0,
**kwargs, **kwargs,
) -> httpx.Response: ) -> httpx.Response:
"""HTTP 요청을 수행합니다. """HTTP 요청을 수행합니다.
@ -319,7 +225,7 @@ class CreatomateService:
Args: Args:
method: HTTP 메서드 ("GET", "POST", etc.) method: HTTP 메서드 ("GET", "POST", etc.)
url: 요청 URL url: 요청 URL
timeout: 요청 타임아웃 (). None이면 기본값 사용 timeout: 요청 타임아웃 ()
**kwargs: httpx 요청에 전달할 추가 인자 **kwargs: httpx 요청에 전달할 추가 인자
Returns: Returns:
@ -330,18 +236,15 @@ class CreatomateService:
""" """
logger.info(f"[Creatomate] {method} {url}") 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() client = await get_shared_client()
if method.upper() == "GET": if method.upper() == "GET":
response = await client.get( response = await client.get(
url, headers=self.headers, timeout=actual_timeout, **kwargs url, headers=self.headers, timeout=timeout, **kwargs
) )
elif method.upper() == "POST": elif method.upper() == "POST":
response = await client.post( response = await client.post(
url, headers=self.headers, timeout=actual_timeout, **kwargs url, headers=self.headers, timeout=timeout, **kwargs
) )
else: else:
raise ValueError(f"Unsupported HTTP method: {method}") raise ValueError(f"Unsupported HTTP method: {method}")
@ -352,7 +255,7 @@ class CreatomateService:
async def get_all_templates_data(self) -> dict: async def get_all_templates_data(self) -> dict:
"""모든 템플릿 정보를 조회합니다.""" """모든 템플릿 정보를 조회합니다."""
url = f"{self.BASE_URL}/v1/templates" 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() response.raise_for_status()
return response.json() return response.json()
@ -385,7 +288,7 @@ class CreatomateService:
# API 호출 # API 호출
url = f"{self.BASE_URL}/v1/templates/{template_id}" 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() response.raise_for_status()
data = response.json() data = response.json()
@ -439,7 +342,6 @@ class CreatomateService:
image_url_list: list[str], image_url_list: list[str],
lyric: str, lyric: str,
music_url: str, music_url: str,
address: str = None
) -> dict: ) -> dict:
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다. """템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
@ -466,8 +368,9 @@ class CreatomateService:
idx % len(image_url_list) idx % len(image_url_list)
] ]
case "text": case "text":
if "address_input" in template_component_name: modifications[template_component_name] = lyric_splited[
modifications[template_component_name] = address idx % len(lyric_splited)
]
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
@ -479,11 +382,12 @@ class CreatomateService:
image_url_list: list[str], image_url_list: list[str],
lyric: str, lyric: str,
music_url: str, music_url: str,
address: str = None
) -> dict: ) -> dict:
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다.""" """elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
template_component_data = self.parse_template_component_name(elements) template_component_data = self.parse_template_component_name(elements)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {} modifications = {}
for idx, (template_component_name, template_type) in enumerate( for idx, (template_component_name, template_type) in enumerate(
@ -495,8 +399,9 @@ class CreatomateService:
idx % len(image_url_list) idx % len(image_url_list)
] ]
case "text": case "text":
if "address_input" in template_component_name: modifications[template_component_name] = lyric_splited[
modifications[template_component_name] = address idx % len(lyric_splited)
]
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
@ -515,8 +420,7 @@ class CreatomateService:
case "video": case "video":
element["source"] = modification[element["name"]] element["source"] = modification[element["name"]]
case "text": case "text":
#element["source"] = modification[element["name"]] element["source"] = modification.get(element["name"], "")
element["text"] = modification.get(element["name"], "")
case "composition": case "composition":
for minor in element["elements"]: for minor in element["elements"]:
recursive_modify(minor) recursive_modify(minor)
@ -529,149 +433,30 @@ class CreatomateService:
async def make_creatomate_call( async def make_creatomate_call(
self, template_id: str, modifications: dict self, template_id: str, modifications: dict
) -> dict: ) -> dict:
"""Creatomate에 렌더링 요청을 보냅니다 (재시도 로직 포함). """Creatomate에 렌더링 요청을 보냅니다.
Args:
template_id: Creatomate 템플릿 ID
modifications: 수정사항 딕셔너리
Returns:
Creatomate API 응답 데이터
Raises:
CreatomateResponseError: API 오류 또는 재시도 실패
Note: Note:
response에 요청 정보가 있으니 폴링 필요 response에 요청 정보가 있으니 폴링 필요
""" """
url = f"{self.BASE_URL}/v2/renders" url = f"{self.BASE_URL}/v2/renders"
payload = { data = {
"template_id": template_id, "template_id": template_id,
"modifications": modifications, "modifications": modifications,
} }
response = await self._request("POST", url, timeout=60.0, json=data)
last_error: Exception | None = None response.raise_for_status()
return response.json()
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)},
)
async def make_creatomate_custom_call(self, source: dict) -> dict: async def make_creatomate_custom_call(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다 (재시도 로직 포함). """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
Args:
source: 렌더링 소스 딕셔너리
Returns:
Creatomate API 응답 데이터
Raises:
CreatomateResponseError: API 오류 또는 재시도 실패
Note: Note:
response에 요청 정보가 있으니 폴링 필요 response에 요청 정보가 있으니 폴링 필요
""" """
url = f"{self.BASE_URL}/v2/renders" url = f"{self.BASE_URL}/v2/renders"
response = await self._request("POST", url, timeout=60.0, json=source)
last_error: Exception | None = None response.raise_for_status()
return response.json()
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)},
)
# 하위 호환성을 위한 별칭 (deprecated) # 하위 호환성을 위한 별칭 (deprecated)
async def make_creatomate_custom_call_async(self, source: dict) -> dict: async def make_creatomate_custom_call_async(self, source: dict) -> dict:
@ -700,7 +485,7 @@ class CreatomateService:
- failed: 실패 - failed: 실패
""" """
url = f"{self.BASE_URL}/v1/renders/{render_id}" 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() response.raise_for_status()
return response.json() return response.json()
@ -734,8 +519,8 @@ class CreatomateService:
def extend_template_duration(self, template: dict, target_duration: float) -> dict: def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다.""" """템플릿의 duration을 target_duration으로 확장합니다."""
template["duration"] = target_duration + 0.5 # 늘린것보단 짧게 target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것 template["duration"] = target_duration
total_template_duration = self.calc_scene_duration(template) total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template) new_template = copy.deepcopy(template)
@ -757,7 +542,7 @@ class CreatomateService:
return new_template 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 duration = end_sec - start_sec
text_scene = copy.deepcopy(text_template) text_scene = copy.deepcopy(text_template)
text_scene["name"] = f"Caption-{lyric_index}" text_scene["name"] = f"Caption-{lyric_index}"
@ -765,24 +550,13 @@ class CreatomateService:
text_scene["time"] = start_sec text_scene["time"] = start_sec
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}" text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
text_scene["elements"][0]["text"] = lyric_text 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 return text_scene
def get_text_template(self): def get_text_template(self):
match self.orientation: match self.orientation:
case "vertical": case "vertical":
return text_template_v_3 return text_template_v_2
case "horizontal": case "horizontal":
return text_template_h_1 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

View File

@ -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

View File

@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
import logging import logging
import sys import sys
from datetime import datetime
from functools import lru_cache from functools import lru_cache
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from typing import Literal from typing import Literal
from app.utils.timezone import today_str
from config import log_settings from config import log_settings
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리) # 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler:
global _shared_file_handler global _shared_file_handler
if _shared_file_handler is None: if _shared_file_handler is None:
today = today_str() today = datetime.today().strftime("%Y-%m-%d")
log_file = LOG_DIR / f"{today}_app.log" log_file = LOG_DIR / f"{today}_app.log"
_shared_file_handler = RotatingFileHandler( _shared_file_handler = RotatingFileHandler(
@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler:
global _shared_error_handler global _shared_error_handler
if _shared_error_handler is None: if _shared_error_handler is None:
today = today_str() today = datetime.today().strftime("%Y-%m-%d")
log_file = LOG_DIR / f"{today}_error.log" log_file = LOG_DIR / f"{today}_error.log"
_shared_error_handler = RotatingFileHandler( _shared_error_handler = RotatingFileHandler(

View File

@ -1,13 +1,8 @@
import asyncio import asyncio
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
from urllib import parse from urllib import parse
import time
from app.utils.logger import get_logger
# 로거 설정 class nvMapPwScraper():
logger = get_logger("pwscraper")
class NvMapPwScraper():
# cls vars # cls vars
is_ready = False is_ready = False
_playwright = None _playwright = None
@ -15,8 +10,7 @@ class NvMapPwScraper():
_context = None _context = None
_win_width = 1280 _win_width = 1280
_win_height = 720 _win_height = 720
_max_retry = 3 _max_retry = 30 # place id timeout threshold seconds
_timeout = 60 # place id timeout threshold seconds
# instance var # instance var
page = None page = None
@ -96,56 +90,24 @@ patchedGetter.toString();''')
await page.goto(url, wait_until=wait_until, timeout=timeout) await page.goto(url, wait_until=wait_until, timeout=timeout)
async def get_place_id_url(self, selected): 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): await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
# raise Exception("Failed to identify place id. loading timeout")
# else: if "/place/" in self.page.url:
# raise Exception("Failed to identify place id. item is ambiguous") 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")

View File

@ -30,7 +30,7 @@ class NvMapScraper:
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql" GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
REQUEST_TIMEOUT = 120 # 초 REQUEST_TIMEOUT = 120 # 초
data_source_identifier = "nv"
OVERVIEW_QUERY: str = """ OVERVIEW_QUERY: str = """
query getAccommodation($id: String!, $deviceType: String) { query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) { 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) data = await self._call_get_accommodation(place_id)
self.rawdata = data self.rawdata = data
fac_data = await self._get_facility_string(place_id) 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.rawdata["facilities"] = fac_data
self.image_link_list = [ self.image_link_list = [
nv_image["origin"] nv_image["origin"]

View File

@ -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
}
}
}

View File

@ -4,7 +4,6 @@ You are a content marketing expert, brand strategist, and creative songwriter
specializing in Korean pension / accommodation businesses. specializing in Korean pension / accommodation businesses.
You create lyrics strictly based on Brand & Marketing Intelligence analysis You create lyrics strictly based on Brand & Marketing Intelligence analysis
and optimized for viral short-form video content. and optimized for viral short-form video content.
Marketing Intelligence Report is background reference.
[INPUT] [INPUT]
Business Name: {customer_name} Business Name: {customer_name}

View File

@ -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
}
}
}

View File

@ -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"]

View File

@ -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
}
}
}

View File

@ -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()

View File

@ -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
}
}
}

View File

@ -0,0 +1,53 @@
입력 :
분석 보고서
{report}
셀링 포인트
{selling_points}
위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.
조건:
각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것
태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여
- 3 ~ 6단어 이내
- 명사 또는 명사형 키워드로 작성
- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것
- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함
- 전체 셀링 포인트 개수는 5~7개로 제한
출력 형식:
[카테고리명]
(태그 키워드)
- 한 줄 설명 문구
예시:
[공간 정체성]
(100년 적산가옥 · 시간의 결)
- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간
[입지 & 희소성]
(말랭이마을 · 로컬 히든플레이스)
- 관광지가 아닌, 군산을 아는 사람의 선택
[프라이버시]
(독채 숙소 · 프라이빗 스테이)
- 누구의 방해도 없는 완전한 휴식 구조
[비주얼 경쟁력]
(감성 인테리어 · 자연광 스폿)
- 찍는 순간 콘텐츠가 되는 공간 설계
[타깃 최적화]
(커플 · 소규모 여행)
- 둘에게 가장 이상적인 공간 밀도
[체류 경험]
(아무것도 안 해도 되는 하루)
- 일정 없이도 만족되는 하루 루틴
[브랜드 포지션]
(호텔도 펜션도 아닌 아지트)
- 다시 돌아오고 싶은 개인적 장소

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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"]

View File

@ -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"]

View File

@ -1,65 +1,76 @@
import os, json import os, json
from pydantic import BaseModel from abc import ABCMeta
from config import prompt_settings from config import prompt_settings
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.prompts.schemas import *
logger = get_logger("prompt") logger = get_logger("prompt")
class Prompt(): class Prompt():
prompt_name : str # ex) marketing_prompt
prompt_template_path : str #프롬프트 경로 prompt_template_path : str #프롬프트 경로
prompt_template : str # fstring 포맷 prompt_template : str # fstring 포맷
prompt_model : str prompt_input : list
prompt_output : dict
prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐 prompt_model : str
prompt_output_class = BaseModel
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_template_path = prompt_template_path
self.prompt_input_class = prompt_input_class self.prompt_template, prompt_dict = self.read_prompt()
self.prompt_output_class = prompt_output_class self.prompt_input = prompt_dict['prompt_variables']
self.prompt_template = self.read_prompt() self.prompt_output = prompt_dict['output_format']
self.prompt_model = prompt_model self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
def _reload_prompt(self): 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]: 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() 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: 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 = self.prompt_template
build_template = build_template.format(**verified_input.model_dump())
logger.debug(f"build_template: {build_template}") logger.debug(f"build_template: {build_template}")
logger.debug(f"input_data: {input_data}") logger.debug(f"input_data: {input_data}")
build_template = build_template.format(**input_data)
return build_template 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( marketing_prompt = Prompt(
prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME), prompt_name=prompt_settings.MARKETING_PROMPT_NAME,
prompt_input_class = MarketingPromptInput, prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_NAME)
prompt_output_class = MarketingPromptOutput, )
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
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( lyric_prompt = Prompt(
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME), prompt_name=prompt_settings.LYLIC_PROMPT_NAME,
prompt_input_class = LyricPromptInput, prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYLIC_PROMPT_NAME)
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
) )
def reload_all_prompt(): def reload_all_prompt():
marketing_prompt._reload_prompt() marketing_prompt._reload_prompt()
lyric_prompt._reload_prompt() summarize_prompt._reload_prompt()
yt_upload_prompt._reload_prompt() lyric_prompt._reload_prompt()

Some files were not shown because too many files have changed in this diff Show More