Compare commits

..

No commits in common. "main" and "feature-youtube-upload" have entirely different histories.

133 changed files with 4080 additions and 11571 deletions

3
.gitignore vendored
View File

@ -50,6 +50,3 @@ logs/
*.yml *.yml
Dockerfile Dockerfile
.dockerignore .dockerignore
zzz/
credentials/service_account.json

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

@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10
## 참고 ## 참고
- **본인이 소유한 프로젝트의 영상만 반환됩니다.** - **본인이 소유한 프로젝트의 영상만 반환됩니다.**
- status가 'completed' 영상만 반환됩니다. - status가 'completed' 영상만 반환됩니다.
- 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다. - 재생성된 영상 포함 모든 영상이 반환됩니다.
- created_at 기준 내림차순 정렬됩니다. - created_at 기준 내림차순 정렬됩니다.
""", """,
response_model=PaginatedResponse[VideoListItem], response_model=PaginatedResponse[VideoListItem],
@ -70,50 +70,112 @@ async def get_videos(
) -> PaginatedResponse[VideoListItem]: ) -> PaginatedResponse[VideoListItem]:
"""완료된 영상 목록을 페이지네이션하여 반환합니다.""" """완료된 영상 목록을 페이지네이션하여 반환합니다."""
logger.info( logger.info(
f"[get_videos] START - user: {current_user.user_uuid}, " f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
f"page: {pagination.page}, page_size: {pagination.page_size}"
) )
logger.debug(f"[get_videos] current_user.user_uuid: {current_user.user_uuid}")
try: try:
offset = (pagination.page - 1) * pagination.page_size offset = (pagination.page - 1) * pagination.page_size
# 서브쿼리: task_id별 최신 Video ID 추출 # DEBUG: 각 조건별 데이터 수 확인
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치 # 1) 전체 Video 수
latest_video_ids = ( all_videos_result = await session.execute(select(func.count(Video.id)))
select(func.max(Video.id).label("latest_id")) all_videos_count = all_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 전체 Video 수: {all_videos_count}")
# 2) completed 상태 Video 수
completed_videos_result = await session.execute(
select(func.count(Video.id)).where(Video.status == "completed")
)
completed_videos_count = completed_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - completed 상태 Video 수: {completed_videos_count}")
# 3) is_deleted=False인 Video 수
not_deleted_videos_result = await session.execute(
select(func.count(Video.id)).where(Video.is_deleted == False)
)
not_deleted_videos_count = not_deleted_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - is_deleted=False Video 수: {not_deleted_videos_count}")
# 4) 전체 Project 수 및 user_uuid 값 확인
all_projects_result = await session.execute(
select(Project.id, Project.user_uuid, Project.is_deleted)
)
all_projects = all_projects_result.all()
logger.debug(f"[get_videos] DEBUG - 전체 Project 수: {len(all_projects)}")
for p in all_projects:
logger.debug(
f"[get_videos] DEBUG - Project: id={p.id}, user_uuid={p.user_uuid}, "
f"user_uuid_type={type(p.user_uuid)}, is_deleted={p.is_deleted}"
)
# 4-1) 현재 사용자 UUID 타입 확인
logger.debug(
f"[get_videos] DEBUG - current_user.user_uuid={current_user.user_uuid}, "
f"type={type(current_user.user_uuid)}"
)
# 4-2) 현재 사용자 소유 Project 수
user_projects_result = await session.execute(
select(func.count(Project.id)).where(
Project.user_uuid == current_user.user_uuid,
Project.is_deleted == False,
)
)
user_projects_count = user_projects_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 현재 사용자 소유 Project 수: {user_projects_count}")
# 5) 현재 사용자 소유 + completed + 미삭제 Video 수
user_completed_videos_result = await session.execute(
select(func.count(Video.id))
.join(Project, Video.project_id == Project.id) .join(Project, Video.project_id == Project.id)
.where( .where(
Project.user_uuid == current_user.user_uuid, Project.user_uuid == current_user.user_uuid,
Video.status == "completed", Video.status == "completed",
Video.is_deleted == False, # noqa: E712 Video.is_deleted == False,
Project.is_deleted == False, # noqa: E712 Project.is_deleted == False,
) )
.group_by(Video.task_id) )
.subquery() user_completed_videos_count = user_completed_videos_result.scalar() or 0
logger.debug(
f"[get_videos] DEBUG - 현재 사용자 소유 + completed + 미삭제 Video 수: {user_completed_videos_count}"
) )
# 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만) # 기본 조건: 현재 사용자 소유, completed 상태, 미삭제
count_query = select(func.count(Video.id)).where( base_conditions = [
Video.id.in_(select(latest_video_ids.c.latest_id)) Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
Video.is_deleted == False,
Project.is_deleted == False,
]
# 쿼리 1: 전체 개수 조회 (모든 영상)
count_query = (
select(func.count(Video.id))
.join(Project, Video.project_id == Project.id)
.where(*base_conditions)
) )
total_result = await session.execute(count_query) total_result = await session.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 전체 영상 개수 (total): {total}")
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만) # 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상)
data_query = ( query = (
select(Video, Project) select(Video, Project)
.join(Project, Video.project_id == Project.id) .join(Project, Video.project_id == Project.id)
.where(Video.id.in_(select(latest_video_ids.c.latest_id))) .where(*base_conditions)
.order_by(Video.created_at.desc()) .order_by(Video.created_at.desc())
.offset(offset) .offset(offset)
.limit(pagination.page_size) .limit(pagination.page_size)
) )
result = await session.execute(data_query) result = await session.execute(query)
rows = result.all() rows = result.all()
logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}")
# VideoListItem으로 변환 # VideoListItem으로 변환 (JOIN 결과에서 바로 추출)
items = [ items = []
VideoListItem( for video, project in rows:
item = VideoListItem(
video_id=video.id, video_id=video.id,
store_name=project.store_name, store_name=project.store_name,
region=project.region, region=project.region,
@ -121,8 +183,7 @@ async def get_videos(
result_movie_url=video.result_movie_url, result_movie_url=video.result_movie_url,
created_at=video.created_at, created_at=video.created_at,
) )
for video, project in rows items.append(item)
]
response = PaginatedResponse.create( response = PaginatedResponse.create(
items=items, items=items,

View File

@ -24,11 +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)")
# dashboard 테이블 초기화 및 기존 데이터 마이그레이션 (모든 환경)
from app.dashboard.migration import init_dashboard_table
await init_dashboard_table()
await NvMapPwScraper.initiate_scraper() await NvMapPwScraper.initiate_scraper()
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error("Database initialization timed out") logger.error("Database initialization timed out")

View File

@ -291,33 +291,9 @@ def add_exception_handlers(app: FastAPI):
# SocialException 핸들러 추가 # SocialException 핸들러 추가
from app.social.exceptions import SocialException from app.social.exceptions import SocialException
from app.social.exceptions import TokenExpiredError
@app.exception_handler(SocialException) @app.exception_handler(SocialException)
def social_exception_handler(request: Request, exc: SocialException) -> Response: def social_exception_handler(request: Request, exc: SocialException) -> Response:
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}") 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,
)
# DashboardException 핸들러 추가
from app.dashboard.exceptions import DashboardException
@app.exception_handler(DashboardException)
def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response:
if exc.status_code < 500:
logger.warning(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
else:
logger.error(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={ content={
@ -328,10 +304,11 @@ def add_exception_handlers(app: FastAPI):
@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

@ -1,5 +0,0 @@
"""
Dashboard Module
YouTube Analytics API를 활용한 대시보드 기능을 제공합니다.
"""

View File

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

View File

@ -1,3 +0,0 @@
"""
Dashboard Routers
"""

View File

@ -1,7 +0,0 @@
"""
Dashboard V1 Routers
"""
from app.dashboard.api.routers.v1.dashboard import router
__all__ = ["router"]

View File

@ -1,136 +0,0 @@
"""
Dashboard API 라우터
YouTube Analytics 기반 대시보드 통계를 제공합니다.
"""
import logging
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dashboard.utils.redis_cache import delete_cache_pattern
from app.dashboard.schemas import (
CacheDeleteResponse,
ConnectedAccountsResponse,
DashboardResponse,
)
from app.dashboard.services import DashboardService
from app.database.session import get_session
from app.user.dependencies.auth import get_current_user
from app.user.models import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
@router.get(
"/accounts",
response_model=ConnectedAccountsResponse,
summary="연결된 소셜 계정 목록 조회",
description="""
연결된 소셜 계정 목록을 반환합니다.
여러 계정이 연결된 경우, 반환된 `platformUserId` 값을 `/dashboard/stats?platform_user_id=<>` 전달하여 계정을 선택합니다.
""",
)
async def get_connected_accounts(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ConnectedAccountsResponse:
service = DashboardService()
connected = await service.get_connected_accounts(current_user, session)
return ConnectedAccountsResponse(accounts=connected)
@router.get(
"/stats",
response_model=DashboardResponse,
summary="대시보드 통계 조회",
description="""
YouTube Analytics API를 활용한 대시보드 통계를 조회합니다.
## 주요 기능
- 최근 30 업로드 영상 기준 통계 제공
- KPI 지표: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상
- 월별 추이: 최근 12개월 vs 이전 12개월 비교
- 인기 영상 TOP 4
- 시청자 분석: 연령/성별/지역 분포
## 성능 최적화
- 7 YouTube Analytics API를 병렬로 호출
- Redis 캐싱 적용 (TTL: 12시간)
## 사전 조건
- YouTube 계정이 연동되어 있어야 합니다
## 조회 모드
- `day`: 최근 30 통계 (현재 날짜 -2 기준)
- `month`: 최근 12개월 통계 (현재 날짜 -2 기준, 기본값)
## 데이터 특성
- **지연 시간**: 48시간 (2) - 2 14 요청 2 12 자까지 확정
- **업데이트 주기**: 하루 1 (PT 자정, 한국 시간 오후 5~8)
- **실시간 아님**: 전날 데이터가 다음날 확정됩니다
""",
)
async def get_dashboard_stats(
mode: Literal["day", "month"] = Query(
default="month",
description="조회 모드: day(최근 30일), month(최근 12개월)",
),
platform_user_id: str | None = Query(
default=None,
description="사용할 YouTube 채널 ID (platform_user_id)",
),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> DashboardResponse:
service = DashboardService()
return await service.get_stats(mode, platform_user_id, current_user, session)
@router.delete(
"/cache",
response_model=CacheDeleteResponse,
summary="대시보드 캐시 삭제",
description="""
대시보드 Redis 캐시를 삭제합니다. 인증 없이 호출 가능합니다.
삭제 다음 `/stats` 요청 YouTube Analytics API를 새로 호출하여 최신 데이터를 반환합니다.
## 사용 시나리오
- 코드 배포 즉시 최신 데이터 반영이 필요할
- 데이터 이상 발생 캐시 강제 갱신
## 캐시 키 구조
`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
## 파라미터
- `user_uuid`: 삭제할 사용자 UUID (필수)
- `mode`: day / month / all (기본값: all)
""",
)
async def delete_dashboard_cache(
mode: Literal["day", "month", "all"] = Query(
default="all",
description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)",
),
user_uuid: str = Query(
description="대상 사용자 UUID",
),
) -> CacheDeleteResponse:
if mode == "all":
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
message = f"전체 캐시 삭제 완료 ({deleted}개)"
else:
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*:{mode}")
message = f"{mode} 캐시 삭제 완료 ({deleted}개)"
logger.info(
f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}"
)
return CacheDeleteResponse(deleted_count=deleted, message=message)

View File

@ -1,195 +0,0 @@
"""
Dashboard Exceptions
Dashboard API 관련 예외 클래스를 정의합니다.
"""
from fastapi import status
class DashboardException(Exception):
"""Dashboard 기본 예외"""
def __init__(
self,
message: str,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
code: str = "DASHBOARD_ERROR",
):
self.message = message
self.status_code = status_code
self.code = code
super().__init__(self.message)
# =============================================================================
# YouTube Analytics API 관련 예외
# =============================================================================
class YouTubeAPIException(DashboardException):
"""YouTube Analytics API 관련 예외 기본 클래스"""
def __init__(
self,
message: str = "YouTube Analytics API 호출 중 오류가 발생했습니다.",
status_code: int = status.HTTP_502_BAD_GATEWAY,
code: str = "YOUTUBE_API_ERROR",
):
super().__init__(message, status_code, code)
class YouTubeAPIError(YouTubeAPIException):
"""YouTube Analytics API 일반 오류"""
def __init__(self, detail: str = ""):
error_message = "YouTube Analytics API 호출에 실패했습니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_502_BAD_GATEWAY,
code="YOUTUBE_API_FAILED",
)
class YouTubeAuthError(YouTubeAPIException):
"""YouTube 인증 실패"""
def __init__(self, detail: str = ""):
error_message = "YouTube 인증에 실패했습니다. 계정 재연동이 필요합니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_401_UNAUTHORIZED,
code="YOUTUBE_AUTH_FAILED",
)
class YouTubeQuotaExceededError(YouTubeAPIException):
"""YouTube API 할당량 초과"""
def __init__(self):
super().__init__(
message="YouTube API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
code="YOUTUBE_QUOTA_EXCEEDED",
)
class YouTubeDataNotFoundError(YouTubeAPIException):
"""YouTube Analytics 데이터 없음"""
def __init__(self, detail: str = ""):
error_message = "YouTube Analytics 데이터를 찾을 수 없습니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_404_NOT_FOUND,
code="YOUTUBE_DATA_NOT_FOUND",
)
# =============================================================================
# 계정 연동 관련 예외
# =============================================================================
class YouTubeAccountNotConnectedError(DashboardException):
"""YouTube 계정 미연동"""
def __init__(self):
super().__init__(
message="YouTube 계정이 연동되어 있지 않습니다. 먼저 YouTube 계정을 연동해주세요.",
status_code=status.HTTP_403_FORBIDDEN,
code="YOUTUBE_NOT_CONNECTED",
)
class YouTubeAccountSelectionRequiredError(DashboardException):
"""여러 YouTube 계정이 연동된 경우 계정 선택 필요"""
def __init__(self):
super().__init__(
message="연결된 YouTube 계정이 여러 개입니다. platform_user_id 파라미터로 사용할 계정을 선택해주세요.",
status_code=status.HTTP_400_BAD_REQUEST,
code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED",
)
class YouTubeAccountNotFoundError(DashboardException):
"""지정한 YouTube 계정을 찾을 수 없음"""
def __init__(self):
super().__init__(
message="지정한 YouTube 계정을 찾을 수 없습니다.",
status_code=status.HTTP_404_NOT_FOUND,
code="YOUTUBE_ACCOUNT_NOT_FOUND",
)
class YouTubeTokenExpiredError(DashboardException):
"""YouTube 토큰 만료"""
def __init__(self):
super().__init__(
message="YouTube 인증이 만료되었습니다. 계정을 재연동해주세요.",
status_code=status.HTTP_401_UNAUTHORIZED,
code="YOUTUBE_TOKEN_EXPIRED",
)
# =============================================================================
# 데이터 관련 예외
# =============================================================================
class NoVideosFoundError(DashboardException):
"""업로드된 영상 없음"""
def __init__(self):
super().__init__(
message="업로드된 YouTube 영상이 없습니다. 먼저 영상을 업로드해주세요.",
status_code=status.HTTP_404_NOT_FOUND,
code="NO_VIDEOS_FOUND",
)
class DashboardDataError(DashboardException):
"""대시보드 데이터 처리 오류"""
def __init__(self, detail: str = ""):
error_message = "대시보드 데이터 처리 중 오류가 발생했습니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="DASHBOARD_DATA_ERROR",
)
# =============================================================================
# 캐싱 관련 예외 (경고용, 실제로는 raise하지 않고 로깅만 사용)
# =============================================================================
class CacheError(DashboardException):
"""캐시 작업 오류
Note:
예외는 실제로 raise되지 않고,
캐시 실패 로깅만 하고 원본 데이터를 반환합니다.
"""
def __init__(self, operation: str, detail: str = ""):
error_message = f"캐시 {operation} 작업 중 오류가 발생했습니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="CACHE_ERROR",
)

View File

@ -1,112 +0,0 @@
"""
Dashboard Migration
dashboard 테이블 초기화 기존 데이터 마이그레이션을 담당합니다.
서버 기동 create_db_tables() 이후 호출됩니다.
"""
import logging
from sqlalchemy import func, select, text
from sqlalchemy.dialects.mysql import insert
from app.dashboard.models import Dashboard
from app.database.session import AsyncSessionLocal, engine
from app.social.models import SocialUpload
from app.user.models import SocialAccount
logger = logging.getLogger(__name__)
async def _dashboard_table_exists() -> bool:
"""dashboard 테이블 존재 여부 확인"""
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = 'dashboard'"
)
)
return result.scalar() > 0
async def _dashboard_is_empty() -> bool:
"""dashboard 테이블 데이터 존재 여부 확인"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(func.count()).select_from(Dashboard)
)
return result.scalar() == 0
async def _migrate_existing_data() -> None:
"""
SocialUpload(status=completed) Dashboard 마이그레이션.
INSERT IGNORE로 중복 안전하게 삽입.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(
SocialUpload.user_uuid,
SocialUpload.platform,
SocialUpload.platform_video_id,
SocialUpload.platform_url,
SocialUpload.title,
SocialUpload.uploaded_at,
SocialAccount.platform_user_id,
)
.join(SocialAccount, SocialUpload.social_account_id == SocialAccount.id)
.where(
SocialUpload.status == "completed",
SocialUpload.platform_video_id.isnot(None),
SocialUpload.uploaded_at.isnot(None),
SocialAccount.platform_user_id.isnot(None),
)
)
rows = result.all()
if not rows:
logger.info("[DASHBOARD_MIGRATE] 마이그레이션 대상 없음")
return
async with AsyncSessionLocal() as session:
for row in rows:
stmt = (
insert(Dashboard)
.values(
user_uuid=row.user_uuid,
platform=row.platform,
platform_user_id=row.platform_user_id,
platform_video_id=row.platform_video_id,
platform_url=row.platform_url,
title=row.title,
uploaded_at=row.uploaded_at,
)
.prefix_with("IGNORE")
)
await session.execute(stmt)
await session.commit()
logger.info(f"[DASHBOARD_MIGRATE] 마이그레이션 완료 - {len(rows)}건 삽입")
async def init_dashboard_table() -> None:
"""
dashboard 테이블 초기화 진입점.
- 테이블이 없으면 생성 마이그레이션
- 테이블이 있지만 비어있으면 마이그레이션 (DEBUG 모드에서 create_db_tables() 테이블 생성한 경우)
- 테이블이 있고 데이터도 있으면 스킵
"""
if not await _dashboard_table_exists():
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 없음 - 생성 및 마이그레이션 시작")
async with engine.begin() as conn:
await conn.run_sync(
lambda c: Dashboard.__table__.create(c, checkfirst=True)
)
await _migrate_existing_data()
elif await _dashboard_is_empty():
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 비어있음 - 마이그레이션 시작")
await _migrate_existing_data()
else:
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 이미 존재 - 스킵")

View File

@ -1,134 +0,0 @@
"""
Dashboard Models
대시보드 전용 SQLAlchemy 모델을 정의합니다.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database.session import Base
class Dashboard(Base):
"""
채널별 영상 업로드 기록 테이블
YouTube 업로드 완료 채널 ID(platform_user_id) 함께 기록합니다.
SocialUpload.social_account_id는 재연동 변경되므로,
테이블로 채널 기준 안정적인 영상 필터링을 제공합니다.
Attributes:
id: 고유 식별자 (자동 증가)
user_uuid: 사용자 UUID (User.user_uuid 참조)
platform: 플랫폼 (youtube/instagram)
platform_user_id: 채널 ID (재연동 후에도 불변)
platform_video_id: 영상 ID
platform_url: 영상 URL
title: 영상 제목
uploaded_at: SocialUpload 완료 시각
created_at: 레코드 생성 시각
"""
__tablename__ = "dashboard"
__table_args__ = (
UniqueConstraint(
"platform_video_id",
"platform_user_id",
name="uq_vcu_video_channel",
),
Index("idx_vcu_user_platform", "user_uuid", "platform_user_id"),
Index("idx_vcu_uploaded_at", "uploaded_at"),
{
"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),
nullable=False,
comment="사용자 UUID (User.user_uuid 참조)",
)
# ==========================================================================
# 플랫폼 정보
# ==========================================================================
platform: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="플랫폼 (youtube/instagram)",
)
platform_user_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="채널 ID (재연동 후에도 불변)",
)
# ==========================================================================
# 플랫폼 결과
# ==========================================================================
platform_video_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
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="영상 제목",
)
# ==========================================================================
# 시간 정보
# ==========================================================================
uploaded_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
comment="SocialUpload 완료 시각",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="레코드 생성 시각",
)
def __repr__(self) -> str:
return (
f"<Dashboard("
f"id={self.id}, "
f"platform_user_id='{self.platform_user_id}', "
f"platform_video_id='{self.platform_video_id}'"
f")>"
)

View File

@ -1,29 +0,0 @@
"""
Dashboard Schemas
Dashboard API의 요청/응답 스키마를 정의합니다.
"""
from app.dashboard.schemas.dashboard_schema import (
AudienceData,
CacheDeleteResponse,
ConnectedAccount,
ConnectedAccountsResponse,
ContentMetric,
DailyData,
DashboardResponse,
MonthlyData,
TopContent,
)
__all__ = [
"ConnectedAccount",
"ConnectedAccountsResponse",
"ContentMetric",
"DailyData",
"MonthlyData",
"TopContent",
"AudienceData",
"DashboardResponse",
"CacheDeleteResponse",
]

View File

@ -1,283 +0,0 @@
"""
Dashboard API Schemas
대시보드 API의 요청/응답 Pydantic 스키마를 정의합니다.
YouTube Analytics API 데이터를 프론트엔드에 전달하기 위한 모델입니다.
사용 예시:
from app.dashboard.schemas import DashboardResponse, ContentMetric
# 라우터에서 response_model로 사용
@router.get("/dashboard/stats", response_model=DashboardResponse)
async def get_dashboard_stats():
...
"""
from datetime import datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
def to_camel(string: str) -> str:
"""snake_case를 camelCase로 변환
Args:
string: snake_case 문자열 (: "content_metrics")
Returns:
camelCase 문자열 (: "contentMetrics")
Example:
>>> to_camel("content_metrics")
"contentMetrics"
>>> to_camel("this_year")
"thisYear"
"""
components = string.split("_")
return components[0] + "".join(x.capitalize() for x in components[1:])
class ContentMetric(BaseModel):
"""KPI 지표 카드
대시보드 상단에 표시되는 핵심 성과 지표(KPI) 카드입니다.
Attributes:
id: 지표 고유 ID (: "total-views", "total-watch-time", "new-subscribers")
label: 한글 라벨 (: "조회수")
value: 원시 숫자값 (단위: unit 참조, 포맷팅은 프론트에서 처리)
unit: 값의 단위 "count" | "hours" | "minutes"
- count: 조회수, 구독자, 좋아요, 댓글, 공유, 업로드 영상
- hours: 시청시간 (estimatedMinutesWatched / 60)
- minutes: 평균 시청시간 (averageViewDuration / 60)
trend: 이전 기간 대비 증감량 (unit과 동일한 단위)
trend_direction: 증감 방향 ("up": 증가, "down": 감소, "-": 변동 없음)
Example:
>>> metric = ContentMetric(
... id="total-views",
... label="조회수",
... value=1200000.0,
... unit="count",
... trend=3800.0,
... trend_direction="up"
... )
"""
id: str
label: str
value: float
unit: str = "count"
trend: float
trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class MonthlyData(BaseModel):
"""월별 추이 데이터
전년 대비 월별 조회수 비교 데이터입니다.
Attributes:
month: 표시 (: "1월", "2월")
this_year: 올해 해당 조회수
last_year: 작년 해당 조회수
Example:
>>> data = MonthlyData(
... month="1월",
... this_year=150000,
... last_year=120000
... )
"""
month: str
this_year: int = Field(alias="thisYear")
last_year: int = Field(alias="lastYear")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class DailyData(BaseModel):
"""일별 추이 데이터 (mode=day 전용)
최근 30일과 이전 30일의 일별 조회수 비교 데이터입니다.
Attributes:
date: 날짜 표시 (: "1/18", "1/19")
this_period: 최근 30 조회수
last_period: 이전 30 동일 요일 조회수
"""
date: str
this_period: int = Field(alias="thisPeriod")
last_period: int = Field(alias="lastPeriod")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class TopContent(BaseModel):
"""인기 영상
조회수 기준 상위 인기 영상 정보입니다.
Attributes:
id: YouTube 영상 ID
title: 영상 제목
thumbnail: 썸네일 이미지 URL
platform: 플랫폼 ("youtube" 또는 "instagram")
views: 원시 조회수 정수 (포맷팅은 프론트에서 처리, : 125400)
engagement: 참여율 (: "8.2%")
date: 업로드 날짜 (: "2026.01.15")
Example:
>>> content = TopContent(
... id="video-id-1",
... title="힐링 영상",
... thumbnail="https://i.ytimg.com/...",
... platform="youtube",
... views=125400,
... engagement="8.2%",
... date="2026.01.15"
... )
"""
id: str
title: str
thumbnail: str
platform: Literal["youtube", "instagram"]
views: int
engagement: str
date: str
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class AudienceData(BaseModel):
"""시청자 분석 데이터
시청자의 연령대, 성별, 지역 분포 데이터입니다.
Attributes:
age_groups: 연령대별 시청자 비율 리스트
[{"label": "18-24", "percentage": 35}, ...]
gender: 성별 시청자 비율 (YouTube viewerPercentage 누적값)
{"male": 45, "female": 55}
top_regions: 상위 국가 리스트 (최대 5)
[{"region": "대한민국", "percentage": 42}, ...]
Example:
>>> data = AudienceData(
... age_groups=[{"label": "18-24", "percentage": 35}],
... gender={"male": 45, "female": 55},
... top_regions=[{"region": "대한민국", "percentage": 42}]
... )
"""
age_groups: list[dict[str, Any]] = Field(alias="ageGroups")
gender: dict[str, int]
top_regions: list[dict[str, Any]] = Field(alias="topRegions")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class DashboardResponse(BaseModel):
"""대시보드 전체 응답
GET /dashboard/stats 엔드포인트의 전체 응답 스키마입니다.
Attributes:
content_metrics: KPI 지표 카드 리스트 (8)
monthly_data: 월별 추이 데이터 (mode=month 채움, 최근 12개월 vs 이전 12개월)
daily_data: 일별 추이 데이터 (mode=day 채움, 최근 30 vs 이전 30)
top_content: 조회수 기준 인기 영상 TOP 4
audience_data: 시청자 분석 데이터 (연령/성별/지역)
has_uploads: 업로드 영상 존재 여부 (False 모든 지표가 0, 상태 UI 표시용)
Example:
>>> response = DashboardResponse(
... content_metrics=[...],
... monthly_data=[...],
... top_content=[...],
... audience_data=AudienceData(...),
... )
>>> json_str = response.model_dump_json() # JSON 직렬화
"""
content_metrics: list[ContentMetric] = Field(alias="contentMetrics")
monthly_data: list[MonthlyData] = Field(default=[], alias="monthlyData")
daily_data: list[DailyData] = Field(default=[], alias="dailyData")
top_content: list[TopContent] = Field(alias="topContent")
audience_data: AudienceData = Field(alias="audienceData")
has_uploads: bool = Field(default=True, alias="hasUploads")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class ConnectedAccount(BaseModel):
"""연결된 소셜 계정 정보
Attributes:
id: SocialAccount 테이블 PK
platform: 플랫폼 (: "youtube")
platform_username: 플랫폼 사용자명 (: "@channelname")
platform_user_id: 플랫폼 채널 고유 ID 재연동해도 불변.
/dashboard/stats?platform_user_id=<> 으로 계정 선택에 사용
channel_title: YouTube 채널 제목 (SocialAccount.platform_data JSON에서 추출)
connected_at: 연동 일시
is_active: 활성화 상태
"""
id: int
platform: str
platform_user_id: str
platform_username: Optional[str] = None
channel_title: Optional[str] = None
connected_at: datetime
is_active: bool
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class ConnectedAccountsResponse(BaseModel):
"""연결된 소셜 계정 목록 응답
Attributes:
accounts: 연결된 계정 목록
"""
accounts: list[ConnectedAccount]
class CacheDeleteResponse(BaseModel):
"""캐시 삭제 응답
Attributes:
deleted_count: 삭제된 캐시 개수
message: 처리 결과 메시지
"""
deleted_count: int
message: str

View File

@ -1,15 +0,0 @@
"""
Dashboard Services
YouTube Analytics API 연동 데이터 가공 서비스를 제공합니다.
"""
from app.dashboard.services.dashboard_service import DashboardService
from app.dashboard.services.data_processor import DataProcessor
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
__all__ = [
"DashboardService",
"YouTubeAnalyticsService",
"DataProcessor",
]

View File

@ -1,358 +0,0 @@
"""
Dashboard Service
대시보드 비즈니스 로직을 담당합니다.
"""
import json
import logging
from datetime import date, datetime, timedelta
from typing import Literal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dashboard.exceptions import (
YouTubeAccountNotConnectedError,
YouTubeAccountNotFoundError,
YouTubeAccountSelectionRequiredError,
YouTubeTokenExpiredError,
)
from app.dashboard.models import Dashboard
from app.dashboard.utils.redis_cache import get_cache, set_cache
from app.dashboard.schemas import (
AudienceData,
ConnectedAccount,
ContentMetric,
DashboardResponse,
TopContent,
)
from app.dashboard.services.data_processor import DataProcessor
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
from app.social.exceptions import TokenExpiredError
from app.social.services import SocialAccountService
from app.user.models import SocialAccount, User
logger = logging.getLogger(__name__)
class DashboardService:
async def get_connected_accounts(
self,
current_user: User,
session: AsyncSession,
) -> list[ConnectedAccount]:
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == current_user.user_uuid,
SocialAccount.platform == "youtube",
SocialAccount.is_active == True, # noqa: E712
)
)
accounts_raw = result.scalars().all()
connected = []
for acc in accounts_raw:
data = acc.platform_data if isinstance(acc.platform_data, dict) else {}
connected.append(
ConnectedAccount(
id=acc.id,
platform=acc.platform,
platform_username=acc.platform_username,
platform_user_id=acc.platform_user_id,
channel_title=data.get("channel_title"),
connected_at=acc.connected_at,
is_active=acc.is_active,
)
)
logger.info(
f"[ACCOUNTS] YouTube 계정 목록 조회 - "
f"user_uuid={current_user.user_uuid}, count={len(connected)}"
)
return connected
def calculate_date_range(
self, mode: Literal["day", "month"]
) -> tuple[date, date, date, date, date, str]:
"""모드별 날짜 범위 계산. (start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc) 반환"""
today = date.today()
if mode == "day":
end_dt = today - timedelta(days=2)
kpi_end_dt = end_dt
start_dt = end_dt - timedelta(days=29)
prev_start_dt = start_dt - timedelta(days=30)
prev_kpi_end_dt = kpi_end_dt - timedelta(days=30)
period_desc = "최근 30일"
else:
end_dt = today.replace(day=1)
kpi_end_dt = today - timedelta(days=2)
start_month = end_dt.month - 11
if start_month <= 0:
start_month += 12
start_year = end_dt.year - 1
else:
start_year = end_dt.year
start_dt = date(start_year, start_month, 1)
prev_start_dt = start_dt.replace(year=start_dt.year - 1)
try:
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1)
except ValueError:
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28)
period_desc = "최근 12개월"
return start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc
async def resolve_social_account(
self,
current_user: User,
session: AsyncSession,
platform_user_id: str | None,
) -> SocialAccount:
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == current_user.user_uuid,
SocialAccount.platform == "youtube",
SocialAccount.is_active == True, # noqa: E712
)
)
social_accounts_raw = result.scalars().all()
social_accounts = list(social_accounts_raw)
if not social_accounts:
raise YouTubeAccountNotConnectedError()
if platform_user_id is not None:
matched = [a for a in social_accounts if a.platform_user_id == platform_user_id]
if not matched:
raise YouTubeAccountNotFoundError()
return matched[0]
elif len(social_accounts) == 1:
return social_accounts[0]
else:
raise YouTubeAccountSelectionRequiredError()
async def get_video_counts(
self,
current_user: User,
session: AsyncSession,
social_account: SocialAccount,
start_dt: date,
prev_start_dt: date,
prev_kpi_end_dt: date,
) -> tuple[int, int]:
today = date.today()
count_result = await session.execute(
select(func.count())
.select_from(Dashboard)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
Dashboard.uploaded_at >= start_dt,
Dashboard.uploaded_at < today + timedelta(days=1),
)
)
period_video_count = count_result.scalar() or 0
prev_count_result = await session.execute(
select(func.count())
.select_from(Dashboard)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
Dashboard.uploaded_at >= prev_start_dt,
Dashboard.uploaded_at <= prev_kpi_end_dt,
)
)
prev_period_video_count = prev_count_result.scalar() or 0
return period_video_count, prev_period_video_count
async def get_video_ids(
self,
current_user: User,
session: AsyncSession,
social_account: SocialAccount,
) -> tuple[list[str], dict[str, tuple[str, datetime]]]:
result = await session.execute(
select(
Dashboard.platform_video_id,
Dashboard.title,
Dashboard.uploaded_at,
)
.where(
Dashboard.user_uuid == current_user.user_uuid,
Dashboard.platform == "youtube",
Dashboard.platform_user_id == social_account.platform_user_id,
)
.order_by(Dashboard.uploaded_at.desc())
.limit(30)
)
rows = result.all()
video_ids = []
video_lookup: dict[str, tuple[str, datetime]] = {}
for row in rows:
platform_video_id, title, uploaded_at = row
video_ids.append(platform_video_id)
video_lookup[platform_video_id] = (title, uploaded_at)
return video_ids, video_lookup
def build_empty_response(self) -> DashboardResponse:
return DashboardResponse(
content_metrics=[
ContentMetric(id="total-views", label="조회수", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="total-watch-time", label="시청시간", value=0.0, unit="hours", trend=0.0, trend_direction="-"),
ContentMetric(id="avg-view-duration", label="평균 시청시간", value=0.0, unit="minutes", trend=0.0, trend_direction="-"),
ContentMetric(id="new-subscribers", label="신규 구독자", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="likes", label="좋아요", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="comments", label="댓글", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="shares", label="공유", value=0.0, unit="count", trend=0.0, trend_direction="-"),
ContentMetric(id="uploaded-videos", label="업로드 영상", value=0.0, unit="count", trend=0.0, trend_direction="-"),
],
monthly_data=[],
daily_data=[],
top_content=[],
audience_data=AudienceData(age_groups=[], gender={"male": 0, "female": 0}, top_regions=[]),
has_uploads=False,
)
def inject_video_count(
self,
response: DashboardResponse,
period_video_count: int,
prev_period_video_count: int,
) -> None:
for metric in response.content_metrics:
if metric.id == "uploaded-videos":
metric.value = float(period_video_count)
video_trend = float(period_video_count - prev_period_video_count)
metric.trend = video_trend
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
break
async def get_stats(
self,
mode: Literal["day", "month"],
platform_user_id: str | None,
current_user: User,
session: AsyncSession,
) -> DashboardResponse:
logger.info(
f"[DASHBOARD] 통계 조회 시작 - "
f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}"
)
# 1. 날짜 계산
start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc = (
self.calculate_date_range(mode)
)
start_date = start_dt.strftime("%Y-%m-%d")
end_date = end_dt.strftime("%Y-%m-%d")
kpi_end_date = kpi_end_dt.strftime("%Y-%m-%d")
logger.debug(f"[1] 날짜 계산 완료 - period={period_desc}, start={start_date}, end={end_date}")
# 2. YouTube 계정 확인
social_account = await self.resolve_social_account(current_user, session, platform_user_id)
logger.debug(f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}")
# 3. 영상 수 조회
period_video_count, prev_period_video_count = await self.get_video_counts(
current_user, session, social_account, start_dt, prev_start_dt, prev_kpi_end_dt
)
logger.debug(f"[3] 영상 수 - current={period_video_count}, prev={prev_period_video_count}")
# 4. 캐시 조회
cache_key = f"dashboard:{current_user.user_uuid}:{social_account.platform_user_id}:{mode}"
cached_raw = await get_cache(cache_key)
if cached_raw:
try:
payload = json.loads(cached_raw)
logger.info(f"[CACHE HIT] 캐시 반환 - user_uuid={current_user.user_uuid}")
response = DashboardResponse.model_validate(payload["response"])
self.inject_video_count(response, period_video_count, prev_period_video_count)
return response
except (json.JSONDecodeError, KeyError):
logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}")
logger.debug("[4] 캐시 MISS - YouTube API 호출 필요")
# 5. 업로드 영상 조회
video_ids, video_lookup = await self.get_video_ids(current_user, session, social_account)
logger.debug(f"[5] 영상 조회 완료 - count={len(video_ids)}")
if not video_ids:
logger.info(f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - user_uuid={current_user.user_uuid}")
return self.build_empty_response()
# 6. 토큰 유효성 확인
try:
access_token = await SocialAccountService().ensure_valid_token(social_account, session)
except TokenExpiredError:
logger.warning(f"[TOKEN EXPIRED] 재연동 필요 - user_uuid={current_user.user_uuid}")
raise YouTubeTokenExpiredError()
logger.debug("[6] 토큰 유효성 확인 완료")
# 7. YouTube Analytics API 호출
youtube_service = YouTubeAnalyticsService()
raw_data = await youtube_service.fetch_all_metrics(
video_ids=video_ids,
start_date=start_date,
end_date=end_date,
kpi_end_date=kpi_end_date,
access_token=access_token,
mode=mode,
)
logger.debug("[7] YouTube Analytics API 호출 완료")
# 8. TopContent 조립
processor = DataProcessor()
top_content_rows = raw_data.get("top_videos", {}).get("rows", [])
top_content: list[TopContent] = []
for row in top_content_rows[:4]:
if len(row) < 4:
continue
video_id, views, likes, comments = row[0], row[1], row[2], row[3]
meta = video_lookup.get(video_id)
if not meta:
continue
title, uploaded_at = meta
engagement_rate = ((likes + comments) / views * 100) if views > 0 else 0
top_content.append(
TopContent(
id=video_id,
title=title,
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
platform="youtube",
views=int(views),
engagement=f"{engagement_rate:.1f}%",
date=uploaded_at.strftime("%Y.%m.%d"),
)
)
logger.debug(f"[8] TopContent 조립 완료 - count={len(top_content)}")
# 9. 데이터 가공
dashboard_data = processor.process(raw_data, top_content, 0, mode=mode, end_date=end_date)
logger.debug("[9] 데이터 가공 완료")
# 10. 캐시 저장
cache_payload = json.dumps({"response": dashboard_data.model_dump(mode="json")})
cache_success = await set_cache(cache_key, cache_payload, ttl=43200)
if cache_success:
logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}")
else:
logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}")
# 11. 업로드 영상 수 주입
self.inject_video_count(dashboard_data, period_video_count, prev_period_video_count)
logger.info(
f"[DASHBOARD] 통계 조회 완료 - "
f"user_uuid={current_user.user_uuid}, mode={mode}, period={period_desc}, videos={len(video_ids)}"
)
return dashboard_data

View File

@ -1,542 +0,0 @@
"""
YouTube Analytics 데이터 가공 프로세서
YouTube Analytics API의 원본 데이터를 프론트엔드용 Pydantic 스키마로 변환합니다.
"""
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Literal
from app.dashboard.schemas import (
AudienceData,
ContentMetric,
DailyData,
DashboardResponse,
MonthlyData,
TopContent,
)
from app.utils.logger import get_logger
logger = get_logger("dashboard")
_COUNTRY_CODE_MAP: dict[str, str] = {
"KR": "대한민국",
"US": "미국",
"JP": "일본",
"CN": "중국",
"GB": "영국",
"DE": "독일",
"FR": "프랑스",
"CA": "캐나다",
"AU": "호주",
"IN": "인도",
"ID": "인도네시아",
"TH": "태국",
"VN": "베트남",
"PH": "필리핀",
"MY": "말레이시아",
"SG": "싱가포르",
"TW": "대만",
"HK": "홍콩",
"BR": "브라질",
"MX": "멕시코",
"NL": "네덜란드",
"BE": "벨기에",
"SE": "스웨덴",
"NO": "노르웨이",
"FI": "핀란드",
"DK": "덴마크",
"IE": "아일랜드",
"PL": "폴란드",
"CZ": "체코",
"RO": "루마니아",
"HU": "헝가리",
"SK": "슬로바키아",
"SI": "슬로베니아",
"HR": "크로아티아",
"GR": "그리스",
"PT": "포르투갈",
"ES": "스페인",
"IT": "이탈리아",
}
class DataProcessor:
"""YouTube Analytics 데이터 가공 프로세서
YouTube Analytics API의 원본 JSON 데이터를 DashboardResponse 스키마로 변환합니다.
섹션별로 데이터 가공 로직을 분리하여 유지보수성을 향상시켰습니다.
"""
def process(
self,
raw_data: dict[str, Any],
top_content: list[TopContent],
period_video_count: int = 0,
mode: Literal["day", "month"] = "month",
end_date: str = "",
) -> DashboardResponse:
"""YouTube Analytics API 원본 데이터를 DashboardResponse로 변환
Args:
raw_data: YouTube Analytics API 응답 데이터 (mode에 따라 구성 다름)
공통:
- kpi: KPI 메트릭 (조회수, 좋아요, 댓글, 시청시간 )
- top_videos: 인기 영상 데이터
- demographics: 연령/성별 데이터
- region: 지역별 데이터
mode="month" 추가:
- trend_recent: 최근 12개월 월별 조회수
- trend_previous: 이전 12개월 월별 조회수
mode="day" 추가:
- trend_recent: 최근 30 일별 조회수
- trend_previous: 이전 30 일별 조회수
top_content: TopContent 리스트 (라우터에서 Analytics + DB lookup으로 생성)
period_video_count: 조회 기간 업로드된 영상 (DB에서 집계)
mode: 조회 모드 ("month" | "day")
Returns:
DashboardResponse: 프론트엔드용 대시보드 응답 스키마
- mode="month": monthly_data 채움, daily_data=[]
- mode="day": daily_data 채움, monthly_data=[]
Example:
>>> processor = DataProcessor()
>>> response = processor.process(
... raw_data={
... "kpi": {...},
... "monthly_recent": {...},
... "monthly_previous": {...},
... "top_videos": {...},
... "demographics": {...},
... "region": {...},
... },
... top_content=[TopContent(...)],
... mode="month",
... )
"""
logger.debug(
f"[DataProcessor.process] START - "
f"top_content_count={len(top_content)}"
)
# 각 섹션별 데이터 가공 (안전한 딕셔너리 접근)
content_metrics = self._build_content_metrics(
raw_data.get("kpi", {}),
raw_data.get("kpi_previous", {}),
period_video_count,
)
if mode == "month":
monthly_data = self._merge_monthly_data(
raw_data.get("trend_recent", {}),
raw_data.get("trend_previous", {}),
end_date=end_date,
)
daily_data: list[DailyData] = []
else: # mode == "day"
daily_data = self._build_daily_data(
raw_data.get("trend_recent", {}),
raw_data.get("trend_previous", {}),
end_date=end_date,
)
monthly_data = []
audience_data = self._build_audience_data(
raw_data.get("demographics") or {},
raw_data.get("region") or {},
)
logger.debug(
f"[DataProcessor.process] SUCCESS - "
f"mode={mode}, metrics={len(content_metrics)}, "
f"top_content={len(top_content)}"
)
return DashboardResponse(
content_metrics=content_metrics,
monthly_data=monthly_data,
daily_data=daily_data,
top_content=top_content,
audience_data=audience_data,
)
def _build_content_metrics(
self,
kpi_data: dict[str, Any],
kpi_previous_data: dict[str, Any],
period_video_count: int = 0,
) -> list[ContentMetric]:
"""KPI 데이터를 ContentMetric 리스트로 변환
Args:
kpi_data: 최근 기간 KPI 응답
rows[0] = [views, likes, comments, shares,
estimatedMinutesWatched, averageViewDuration,
subscribersGained]
kpi_previous_data: 이전 기간 KPI 응답 (증감률 계산용)
period_video_count: 조회 기간 업로드된 영상 (DB에서 집계)
Returns:
list[ContentMetric]: KPI 지표 카드 리스트 (8)
순서: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상
"""
logger.info(
f"[DataProcessor._build_content_metrics] START - "
f"kpi_keys={list(kpi_data.keys())}"
)
rows = kpi_data.get("rows", [])
if not rows or not rows[0]:
logger.warning(
f"[DataProcessor._build_content_metrics] NO_DATA - " f"rows={rows}"
)
return []
row = rows[0]
prev_rows = kpi_previous_data.get("rows", [])
prev_row = prev_rows[0] if prev_rows else []
def _get(r: list, i: int, default: float = 0.0) -> float:
return r[i] if len(r) > i else default
def _trend(recent: float, previous: float) -> tuple[float, str]:
pct = recent - previous
if pct > 0:
direction = "up"
elif pct < 0:
direction = "down"
else:
direction = "-"
return pct, direction
# 최근 기간
views = _get(row, 0)
likes = _get(row, 1)
comments = _get(row, 2)
shares = _get(row, 3)
estimated_minutes_watched = _get(row, 4)
average_view_duration = _get(row, 5)
subscribers_gained = _get(row, 6)
# 이전 기간
prev_views = _get(prev_row, 0)
prev_likes = _get(prev_row, 1)
prev_comments = _get(prev_row, 2)
prev_shares = _get(prev_row, 3)
prev_minutes_watched = _get(prev_row, 4)
prev_avg_duration = _get(prev_row, 5)
prev_subscribers = _get(prev_row, 6)
views_trend, views_dir = _trend(views, prev_views)
watch_trend, watch_dir = _trend(estimated_minutes_watched, prev_minutes_watched)
duration_trend, duration_dir = _trend(average_view_duration, prev_avg_duration)
subs_trend, subs_dir = _trend(subscribers_gained, prev_subscribers)
likes_trend, likes_dir = _trend(likes, prev_likes)
comments_trend, comments_dir = _trend(comments, prev_comments)
shares_trend, shares_dir = _trend(shares, prev_shares)
logger.info(
f"[DataProcessor._build_content_metrics] SUCCESS - "
f"views={views}({views_trend:+.1f}), "
f"watch_time={estimated_minutes_watched}min({watch_trend:+.1f}), "
f"subscribers={subscribers_gained}({subs_trend:+.1f})"
)
return [
ContentMetric(
id="total-views",
label="조회수",
value=float(views),
unit="count",
trend=round(float(views_trend), 1),
trend_direction=views_dir,
),
ContentMetric(
id="total-watch-time",
label="시청시간",
value=round(estimated_minutes_watched / 60, 1),
unit="hours",
trend=round(watch_trend / 60, 1),
trend_direction=watch_dir,
),
ContentMetric(
id="avg-view-duration",
label="평균 시청시간",
value=round(average_view_duration / 60, 1),
unit="minutes",
trend=round(duration_trend / 60, 1),
trend_direction=duration_dir,
),
ContentMetric(
id="new-subscribers",
label="신규 구독자",
value=float(subscribers_gained),
unit="count",
trend=subs_trend,
trend_direction=subs_dir,
),
ContentMetric(
id="likes",
label="좋아요",
value=float(likes),
unit="count",
trend=likes_trend,
trend_direction=likes_dir,
),
ContentMetric(
id="comments",
label="댓글",
value=float(comments),
unit="count",
trend=comments_trend,
trend_direction=comments_dir,
),
ContentMetric(
id="shares",
label="공유",
value=float(shares),
unit="count",
trend=shares_trend,
trend_direction=shares_dir,
),
ContentMetric(
id="uploaded-videos",
label="업로드 영상",
value=float(period_video_count),
unit="count",
trend=0.0,
trend_direction="-",
),
]
def _merge_monthly_data(
self,
data_recent: dict[str, Any],
data_previous: dict[str, Any],
end_date: str = "",
) -> list[MonthlyData]:
"""최근 12개월과 이전 12개월의 월별 데이터를 병합
end_date 기준 12개월을 명시 생성하여 API가 반환하지 않은 (당월 ) 0으로 포함합니다.
Args:
data_recent: 최근 12개월 월별 조회수 데이터
rows = [["2026-01", 150000], ["2026-02", 180000], ...]
data_previous: 이전 12개월 월별 조회수 데이터
rows = [["2025-01", 120000], ["2025-02", 140000], ...]
end_date: 기준 종료일 (YYYY-MM-DD). 미전달 오늘 사용
Returns:
list[MonthlyData]: 월별 비교 데이터 (12, API 미반환 월은 0)
"""
logger.debug("[DataProcessor._merge_monthly_data] START")
rows_recent = data_recent.get("rows", [])
rows_previous = data_previous.get("rows", [])
map_recent = {row[0]: row[1] for row in rows_recent if len(row) >= 2}
map_previous = {row[0]: row[1] for row in rows_previous if len(row) >= 2}
# end_date 기준 12개월 명시 생성 (API 미반환 당월도 0으로 포함)
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
else:
end_dt = datetime.today()
result = []
for i in range(11, -1, -1):
m = end_dt.month - i
y = end_dt.year
if m <= 0:
m += 12
y -= 1
month_key = f"{y}-{m:02d}"
result.append(
MonthlyData(
month=f"{m}",
this_year=map_recent.get(month_key, 0),
last_year=map_previous.get(f"{y - 1}-{m:02d}", 0),
)
)
logger.debug(
f"[DataProcessor._merge_monthly_data] SUCCESS - count={len(result)}"
)
return result
def _build_daily_data(
self,
data_recent: dict[str, Any],
data_previous: dict[str, Any],
end_date: str = "",
num_days: int = 30,
) -> list[DailyData]:
"""최근 30일과 이전 30일의 일별 데이터를 병합
end_date 기준 num_days개 날짜를 직접 생성하여 YouTube API 응답에
해당 날짜 row가 없어도 0으로 채웁니다 (X축 누락 방지).
Args:
data_recent: 최근 30 일별 조회수 데이터
rows = [["2026-01-20", 5000], ["2026-01-21", 6200], ...]
data_previous: 이전 30 일별 조회수 데이터
rows = [["2025-12-21", 4500], ["2025-12-22", 5100], ...]
end_date: 최근 기간의 마지막 (YYYY-MM-DD). 미전달 rows 마지막 사용
num_days: 표시할 일수 (기본 30)
Returns:
list[DailyData]: 일별 비교 데이터 (num_days개, 데이터 없는 날은 0)
"""
logger.debug("[DataProcessor._build_daily_data] START")
rows_recent = data_recent.get("rows", [])
rows_previous = data_previous.get("rows", [])
# 날짜 → 조회수 맵
map_recent = {row[0]: row[1] for row in rows_recent if len(row) >= 2}
map_previous = {row[0]: row[1] for row in rows_previous if len(row) >= 2}
# end_date 결정: 전달된 값 우선, 없으면 rows 마지막 날짜 사용
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d").date()
elif rows_recent:
end_dt = datetime.strptime(rows_recent[-1][0], "%Y-%m-%d").date()
else:
logger.warning(
"[DataProcessor._build_daily_data] NO_DATA - rows_recent 비어있음"
)
return []
start_dt = end_dt - timedelta(days=num_days - 1)
# 날짜 범위를 직접 생성하여 누락된 날짜도 0으로 채움
result = []
current = start_dt
while current <= end_dt:
date_str = current.strftime("%Y-%m-%d")
date_label = f"{current.month}/{current.day}"
this_views = map_recent.get(date_str, 0)
# 이전 기간: 동일 인덱스 날짜 (current - 30일)
prev_date_str = (current - timedelta(days=num_days)).strftime("%Y-%m-%d")
last_views = map_previous.get(prev_date_str, 0)
result.append(
DailyData(
date=date_label,
this_period=int(this_views),
last_period=int(last_views),
)
)
current += timedelta(days=1)
logger.debug(f"[DataProcessor._build_daily_data] SUCCESS - count={len(result)}")
return result
def _build_audience_data(
self,
demographics_data: dict[str, Any],
geography_data: dict[str, Any],
) -> AudienceData:
"""시청자 분석 데이터 생성
연령대별, 성별, 지역별 시청자 분포를 분석합니다.
Args:
demographics_data: 연령/성별 API 응답
rows = [["age18-24", "male", 45000], ["age18-24", "female", 55000], ...]
geography_data: 지역별 API 응답
rows = [["KR", 1000000], ["US", 500000], ...]
Returns:
AudienceData: 시청자 분석 데이터
- age_groups: 연령대별 비율
- gender: 성별 조회수
- top_regions: 상위 지역 (5)
"""
logger.debug("[DataProcessor._build_audience_data] START")
# === 연령/성별 데이터 처리 ===
demo_rows = demographics_data.get("rows", [])
age_map: dict[str, float] = {}
gender_map_f: dict[str, float] = {"male": 0.0, "female": 0.0}
for row in demo_rows:
if len(row) < 3:
continue
age_group = row[0] # "age18-24"
gender = row[1] # "male" or "female"
viewer_pct = row[2] # viewerPercentage (이미 % 값, 예: 45.5)
# 연령대별 집계: 남녀 비율 합산 (age18-24 → 18-24)
age_label = age_group.replace("age", "")
age_map[age_label] = age_map.get(age_label, 0.0) + viewer_pct
# 성별 집계
if gender in gender_map_f:
gender_map_f[gender] += viewer_pct
# 연령대 5개로 통합: 13-17+18-24 → 13-24, 55-64+65- → 55+
merged_age: dict[str, float] = {
"13-24": age_map.get("13-17", 0.0) + age_map.get("18-24", 0.0),
"25-34": age_map.get("25-34", 0.0),
"35-44": age_map.get("35-44", 0.0),
"45-54": age_map.get("45-54", 0.0),
"55+": age_map.get("55-64", 0.0) + age_map.get("65-", 0.0),
}
age_groups = [
{"label": age, "percentage": int(round(pct))}
for age, pct in merged_age.items()
]
gender_map = {k: int(round(v)) for k, v in gender_map_f.items()}
# === 지역 데이터 처리 ===
geo_rows = geography_data.get("rows", [])
total_geo_views = sum(row[1] for row in geo_rows if len(row) >= 2)
merged_geo: defaultdict[str, int] = defaultdict(int)
for row in geo_rows:
if len(row) >= 2:
merged_geo[self._translate_country_code(row[0])] += row[1]
top_regions = [
{
"region": region,
"percentage": int((views / total_geo_views * 100) if total_geo_views > 0 else 0),
}
for region, views in sorted(merged_geo.items(), key=lambda x: x[1], reverse=True)[:5]
]
logger.debug(
f"[DataProcessor._build_audience_data] SUCCESS - "
f"age_groups={len(age_groups)}, regions={len(top_regions)}"
)
return AudienceData(
age_groups=age_groups,
gender=gender_map,
top_regions=top_regions,
)
@staticmethod
def _translate_country_code(code: str) -> str:
"""국가 코드를 한국어로 변환
ISO 3166-1 alpha-2 국가 코드를 한국어 국가명으로 변환합니다.
Args:
code: ISO 3166-1 alpha-2 국가 코드 (: "KR", "US")
Returns:
str: 한국어 국가명 (매핑되지 않은 경우 원본 코드 반환)
Example:
>>> _translate_country_code("KR")
"대한민국"
>>> _translate_country_code("US")
"미국"
"""
return _COUNTRY_CODE_MAP.get(code, "기타")

View File

@ -1,503 +0,0 @@
"""
YouTube Analytics API 서비스
YouTube Analytics API v2를 호출하여 채널 영상 통계를 조회합니다.
"""
import asyncio
from datetime import datetime, timedelta
from typing import Any, Literal
import httpx
from app.dashboard.exceptions import (
YouTubeAPIError,
YouTubeAuthError,
YouTubeQuotaExceededError,
)
from app.utils.logger import get_logger
logger = get_logger("dashboard")
class YouTubeAnalyticsService:
"""YouTube Analytics API 호출 서비스
YouTube Analytics API v2를 사용하여 채널 통계, 영상 성과,
시청자 분석 데이터를 조회합니다.
API 문서:
https://developers.google.com/youtube/analytics/reference
"""
BASE_URL = "https://youtubeanalytics.googleapis.com/v2/reports"
async def fetch_all_metrics(
self,
video_ids: list[str],
start_date: str,
end_date: str,
access_token: str,
mode: Literal["day", "month"] = "month",
kpi_end_date: str = "",
) -> dict[str, Any]:
"""YouTube Analytics API 호출을 병렬로 실행
Args:
video_ids: YouTube 영상 ID 리스트 (최대 30, 리스트 허용)
start_date: 조회 시작일 (YYYY-MM-DD)
end_date: 조회 종료일 (YYYY-MM-DD)
access_token: YouTube OAuth 2.0 액세스 토큰
mode: 조회 모드 ("month" | "day")
kpi_end_date: KPI 집계 종료일 (미전달 end_date와 동일)
Returns:
dict[str, Any]: API 응답 데이터 (7 )
- kpi: 최근 기간 KPI 메트릭 (조회수, 좋아요, 댓글 )
- kpi_previous: 이전 기간 KPI 메트릭 (trend 계산용)
- trend_recent: 최근 기간 추이 (월별 또는 일별 조회수)
- trend_previous: 이전 기간 추이 (전년 또는 이전 30)
- top_videos: 조회수 기준 인기 영상 TOP 4
- demographics: 연령/성별 시청자 분포
- region: 지역별 조회수 TOP 5
Raises:
YouTubeAPIError: API 호출 실패
YouTubeQuotaExceededError: 할당량 초과
YouTubeAuthError: 인증 실패
Example:
>>> service = YouTubeAnalyticsService()
>>> data = await service.fetch_all_metrics(
... video_ids=["dQw4w9WgXcQ", "jNQXAC9IVRw"],
... start_date="2026-01-01",
... end_date="2026-12-31",
... access_token="ya29.a0..."
... )
"""
logger.debug(
f"[1/7] YouTube Analytics API 병렬 호출 시작 - "
f"video_count={len(video_ids)}, period={start_date}~{end_date}, mode={mode}"
)
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
# kpi_end_date: KPI/top_videos/demographics/region 호출에 사용
# month 모드에서는 현재 월 전체 데이터를 포함하기 위해 end_date(YYYY-MM-01)보다 늦은 날짜 사용
# day 모드 또는 미전달 시 end_date와 동일
_kpi_end = kpi_end_date if kpi_end_date else end_date
if mode == "month":
# 월별 차트: 라우터에서 이미 YYYY-MM-01 형식으로 계산된 날짜 그대로 사용
# recent: start_date ~ end_date (ex. 2025-03-01 ~ 2026-02-01)
# previous: 1년 전 동일 기간 (ex. 2024-03-01 ~ 2025-02-01)
recent_start = start_date
recent_end = end_date
previous_start = f"{int(start_date[:4]) - 1}{start_date[4:]}"
previous_end = f"{int(end_date[:4]) - 1}{end_date[4:]}"
# KPI 이전 기간: _kpi_end 기준 1년 전 (ex. 2026-02-22 → 2025-02-22)
previous_kpi_end = f"{int(_kpi_end[:4]) - 1}{_kpi_end[4:]}"
logger.debug(
f"[월별 데이터] 최근 12개월: {recent_start}~{recent_end}, "
f"이전 12개월: {previous_start}~{previous_end}, "
f"KPI 조회 종료일: {_kpi_end}"
)
else:
# 일별 차트: end_date 기준 최근 30일 / 이전 30일
day_recent_end = end_date
day_recent_start = (end_dt - timedelta(days=29)).strftime("%Y-%m-%d")
day_previous_end = (end_dt - timedelta(days=30)).strftime("%Y-%m-%d")
day_previous_start = (end_dt - timedelta(days=59)).strftime("%Y-%m-%d")
logger.debug(
f"[일별 데이터] 최근 30일: {day_recent_start}~{day_recent_end}, "
f"이전 30일: {day_previous_start}~{day_previous_end}"
)
# 7개 API 호출 태스크 생성 (mode별 선택적)
# [0] KPI(최근), [1] KPI(이전), [2] 추이(최근), [3] 추이(이전), [4] 인기영상, [5] 인구통계, [6] 지역
# mode=month: [2][3] = 월별 데이터 (YYYY-MM-01 형식 필요)
# mode=day: [2][3] = 일별 데이터
if mode == "month":
tasks = [
self._fetch_kpi(video_ids, start_date, _kpi_end, access_token),
self._fetch_kpi(video_ids, previous_start, previous_kpi_end, access_token),
self._fetch_monthly_data(video_ids, recent_start, recent_end, access_token),
self._fetch_monthly_data(video_ids, previous_start, previous_end, access_token),
self._fetch_top_videos(video_ids, start_date, _kpi_end, access_token),
self._fetch_demographics(start_date, _kpi_end, access_token),
self._fetch_region(start_date, _kpi_end, access_token),
]
else: # mode == "day"
tasks = [
self._fetch_kpi(video_ids, start_date, end_date, access_token),
self._fetch_kpi(video_ids, day_previous_start, day_previous_end, access_token),
self._fetch_daily_data(video_ids, day_recent_start, day_recent_end, access_token),
self._fetch_daily_data(video_ids, day_previous_start, day_previous_end, access_token),
self._fetch_top_videos(video_ids, start_date, end_date, access_token),
self._fetch_demographics(start_date, end_date, access_token),
self._fetch_region(start_date, end_date, access_token),
]
# 병렬 실행
results = await asyncio.gather(*tasks, return_exceptions=True)
# 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파)
# demographics(index 5)는 YouTubeAPIError 시 None으로 허용 (YouTube 서버 간헐적 오류 대응)
OPTIONAL_INDICES = {5, 6} # demographics, region
results = list(results)
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(
f"[YouTubeAnalyticsService] API 호출 {i+1}/7 실패: {result.__class__.__name__}"
)
if isinstance(result, (YouTubeAuthError, YouTubeQuotaExceededError)):
raise result
if i in OPTIONAL_INDICES and isinstance(result, YouTubeAPIError):
logger.warning(
f"[YouTubeAnalyticsService] 선택적 API 호출 {i+1}/7 실패, None으로 처리: {result}"
)
results[i] = None
continue
raise YouTubeAPIError(f"데이터 조회 실패: {result.__class__.__name__}")
logger.debug(
f"[7/7] YouTube Analytics API 병렬 호출 완료 - mode={mode}, 성공률 100%"
)
# 각 API 호출 결과 디버그 로깅
labels = [
"kpi",
"kpi_previous",
"trend_recent",
"trend_previous",
"top_videos",
"demographics",
"region",
]
for label, result in zip(labels, results):
rows = result.get("rows") if isinstance(result, dict) else None
row_count = len(rows) if rows else 0
preview = rows[:2] if rows else []
logger.debug(
f"[fetch_all_metrics] {label}: row_count={row_count}, preview={preview}"
)
return {
"kpi": results[0],
"kpi_previous": results[1],
"trend_recent": results[2],
"trend_previous": results[3],
"top_videos": results[4],
"demographics": results[5],
"region": results[6],
}
async def _fetch_kpi(
self,
video_ids: list[str],
start_date: str,
end_date: str,
access_token: str,
) -> dict[str, Any]:
"""전체 KPI 메트릭 조회 (contentMetrics용)
조회수, 좋아요, 댓글, 공유, 시청 시간, 구독자 증감
핵심 성과 지표를 조회합니다.
Args:
video_ids: YouTube 영상 ID 리스트
start_date: 조회 시작일 (YYYY-MM-DD)
end_date: 조회 종료일 (YYYY-MM-DD)
access_token: OAuth 2.0 액세스 토큰
Returns:
dict[str, Any]: YouTube Analytics API 응답
rows[0] = [views, likes, comments, shares,
estimatedMinutesWatched, averageViewDuration,
subscribersGained]
"""
logger.debug(
f"[YouTubeAnalyticsService._fetch_kpi] START - video_count={len(video_ids)}"
)
params = {
"ids": "channel==MINE",
"startDate": start_date,
"endDate": end_date,
"metrics": "views,likes,comments,shares,estimatedMinutesWatched,averageViewDuration,subscribersGained",
"filters": f"video=={','.join(video_ids)}",
}
result = await self._call_api(params, access_token)
logger.debug("[YouTubeAnalyticsService._fetch_kpi] SUCCESS")
return result
async def _fetch_monthly_data(
self,
video_ids: list[str],
start_date: str,
end_date: str,
access_token: str,
) -> dict[str, Any]:
"""월별 조회수 데이터 조회
지정된 기간의 월별 조회수를 조회합니다.
최근 12개월과 이전 12개월을 각각 조회하여 비교합니다.
Args:
video_ids: YouTube 영상 ID 리스트
start_date: 조회 시작일 (YYYY-MM-DD)
end_date: 조회 종료일 (YYYY-MM-DD)
access_token: OAuth 2.0 액세스 토큰
Returns:
dict[str, Any]: YouTube Analytics API 응답
rows = [["2026-01", 150000], ["2026-02", 180000], ...]
"""
logger.debug(
f"[YouTubeAnalyticsService._fetch_monthly_data] START - "
f"period={start_date}~{end_date}"
)
params = {
"ids": "channel==MINE",
"startDate": start_date,
"endDate": end_date,
"dimensions": "month",
"metrics": "views",
"filters": f"video=={','.join(video_ids)}",
"sort": "month",
}
result = await self._call_api(params, access_token)
logger.debug(
f"[YouTubeAnalyticsService._fetch_monthly_data] SUCCESS - "
f"period={start_date}~{end_date}"
)
return result
async def _fetch_daily_data(
self,
video_ids: list[str],
start_date: str,
end_date: str,
access_token: str,
) -> dict[str, Any]:
"""일별 조회수 데이터 조회
지정된 기간의 일별 조회수를 조회합니다.
최근 30일과 이전 30일을 각각 조회하여 비교합니다.
Args:
video_ids: YouTube 영상 ID 리스트
start_date: 조회 시작일 (YYYY-MM-DD)
end_date: 조회 종료일 (YYYY-MM-DD)
access_token: OAuth 2.0 액세스 토큰
Returns:
dict[str, Any]: YouTube Analytics API 응답
rows = [["2026-01-18", 5000], ["2026-01-19", 6200], ...]
"""
logger.debug(
f"[YouTubeAnalyticsService._fetch_daily_data] START - "
f"period={start_date}~{end_date}"
)
params = {
"ids": "channel==MINE",
"startDate": start_date,
"endDate": end_date,
"dimensions": "day",
"metrics": "views",
"filters": f"video=={','.join(video_ids)}",
"sort": "day",
}
result = await self._call_api(params, access_token)
logger.debug(
f"[YouTubeAnalyticsService._fetch_daily_data] SUCCESS - "
f"period={start_date}~{end_date}"
)
return result
async def _fetch_top_videos(
self,
video_ids: list[str],
start_date: str,
end_date: str,
access_token: str,
) -> dict[str, Any]:
"""영상별 조회수 조회 (topContent용)
조회수 기준 상위 4 영상의 성과 데이터를 조회합니다.
Args:
video_ids: YouTube 영상 ID 리스트
start_date: 조회 시작일 (YYYY-MM-DD)
end_date: 조회 종료일 (YYYY-MM-DD)
access_token: OAuth 2.0 액세스 토큰
Returns:
dict[str, Any]: YouTube Analytics API 응답
rows = [["video_id", views, likes, comments], ...]
조회수 내림차순으로 정렬된 상위 4 영상
"""
logger.debug("[YouTubeAnalyticsService._fetch_top_videos] START")
params = {
"ids": "channel==MINE",
"startDate": start_date,
"endDate": end_date,
"dimensions": "video",
"metrics": "views,likes,comments",
"filters": f"video=={','.join(video_ids)}",
"sort": "-views",
"maxResults": "4",
}
result = await self._call_api(params, access_token)
logger.debug("[YouTubeAnalyticsService._fetch_top_videos] SUCCESS")
return result
async def _fetch_demographics(
self,
start_date: str,
end_date: str,
access_token: str,
) -> dict[str, Any]:
"""연령/성별 분포 조회 (채널 전체 기준)
시청자의 연령대별, 성별 시청 비율을 조회합니다.
Note:
YouTube Analytics API 제약: ageGroup/gender 차원은 video 필터와 혼용 불가.
채널 전체 시청자 기준 데이터를 반환합니다.
Args:
start_date: 조회 시작일 (YYYY-MM-DD)
end_date: 조회 종료일 (YYYY-MM-DD)
access_token: OAuth 2.0 액세스 토큰
Returns:
dict[str, Any]: YouTube Analytics API 응답
rows = [["age18-24", "female", 45.5], ["age18-24", "male", 32.1], ...]
"""
logger.debug("[YouTubeAnalyticsService._fetch_demographics] START")
# Demographics 보고서는 video 필터 미지원 → 채널 전체 기준 데이터
# 지원 filters: country, province, continent, subContinent, liveOrOnDemand, subscribedStatus
params = {
"ids": "channel==MINE",
"startDate": start_date,
"endDate": end_date,
"dimensions": "ageGroup,gender",
"metrics": "viewerPercentage",
}
result = await self._call_api(params, access_token)
logger.debug("[YouTubeAnalyticsService._fetch_demographics] SUCCESS")
return result
async def _fetch_region(
self,
start_date: str,
end_date: str,
access_token: str,
) -> dict[str, Any]:
"""지역별 조회수 조회
지역별 조회수 분포를 조회합니다 (상위 5).
Args:
start_date: 조회 시작일 (YYYY-MM-DD)
end_date: 조회 종료일 (YYYY-MM-DD)
access_token: OAuth 2.0 액세스 토큰
Returns:
dict[str, Any]: YouTube Analytics API 응답
rows = [["KR", 1000000], ["US", 500000], ...]
조회수 내림차순으로 정렬된 상위 5 국가
"""
logger.debug("[YouTubeAnalyticsService._fetch_region] START")
params = {
"ids": "channel==MINE",
"startDate": start_date,
"endDate": end_date,
"dimensions": "country",
"metrics": "views",
"sort": "-views",
}
result = await self._call_api(params, access_token)
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
return result
async def _call_api(
self,
params: dict[str, str],
access_token: str,
) -> dict[str, Any]:
"""YouTube Analytics API 호출 공통 로직
모든 API 호출에 공통적으로 사용되는 HTTP 요청 로직입니다.
인증 헤더 추가, 에러 처리, 응답 파싱을 담당합니다.
Args:
params: API 요청 파라미터 (dimensions, metrics, filters )
access_token: OAuth 2.0 액세스 토큰
Returns:
dict[str, Any]: YouTube Analytics API JSON 응답
Raises:
YouTubeQuotaExceededError: 할당량 초과 (429)
YouTubeAuthError: 인증 실패 (401, 403)
YouTubeAPIError: 기타 API 오류
Note:
- 타임아웃: 30
- 할당량 초과 자동으로 YouTubeQuotaExceededError 발생
- 인증 실패 자동으로 YouTubeAuthError 발생
"""
headers = {"Authorization": f"Bearer {access_token}"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
self.BASE_URL,
params=params,
headers=headers,
)
# 할당량 초과 체크
if response.status_code == 429:
logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED")
raise YouTubeQuotaExceededError()
# 인증 실패 체크
if response.status_code in (401, 403):
logger.warning(
f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}"
)
raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}")
# HTTP 에러 체크
response.raise_for_status()
return response.json()
except (YouTubeAuthError, YouTubeQuotaExceededError):
raise # 이미 처리된 예외는 그대로 전파
except httpx.HTTPStatusError as e:
logger.error(
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
f"status={e.response.status_code}, body={e.response.text[:500]}"
)
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
except httpx.RequestError as e:
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
raise YouTubeAPIError(f"네트워크 오류: {e}")
except Exception as e:
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
raise YouTubeAPIError(f"알 수 없는 오류: {e}")

View File

@ -1,71 +0,0 @@
"""
Dashboard Background Tasks
업로드 완료 Dashboard 테이블에 레코드를 삽입하는 백그라운드 태스크입니다.
"""
import logging
from sqlalchemy import select
from sqlalchemy.dialects.mysql import insert
from app.dashboard.models import Dashboard
from app.database.session import BackgroundSessionLocal
from app.social.models import SocialUpload
from app.user.models import SocialAccount
logger = logging.getLogger(__name__)
async def insert_dashboard(upload_id: int) -> None:
"""
Dashboard 레코드 삽입
SocialUpload(id=upload_id) 완료 데이터를 DB에서 조회하여 Dashboard에 삽입합니다.
UniqueConstraint(platform_video_id, platform_user_id) 충돌 스킵(INSERT IGNORE).
"""
try:
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(
SocialUpload.user_uuid,
SocialUpload.platform,
SocialUpload.platform_video_id,
SocialUpload.platform_url,
SocialUpload.title,
SocialUpload.uploaded_at,
SocialAccount.platform_user_id,
)
.join(SocialAccount, SocialUpload.social_account_id == SocialAccount.id)
.where(SocialUpload.id == upload_id)
)
row = result.one_or_none()
if not row:
logger.warning(f"[dashboard] upload_id={upload_id} 데이터 없음")
return
stmt = (
insert(Dashboard)
.values(
user_uuid=row.user_uuid,
platform=row.platform,
platform_user_id=row.platform_user_id,
platform_video_id=row.platform_video_id,
platform_url=row.platform_url,
title=row.title,
uploaded_at=row.uploaded_at,
)
.prefix_with("IGNORE")
)
await session.execute(stmt)
await session.commit()
logger.info(
f"[dashboard] 삽입 완료 - "
f"upload_id={upload_id}, platform_video_id={row.platform_video_id}"
)
except Exception as e:
logger.error(
f"[dashboard] 삽입 실패 - upload_id={upload_id}, error={e}"
)

View File

@ -1,173 +0,0 @@
"""
Redis 캐싱 유틸리티
Dashboard API 성능 최적화를 위한 Redis 캐싱 기능을 제공합니다.
YouTube Analytics API 호출 결과를 캐싱하여 중복 요청을 방지합니다.
"""
from typing import Optional
from redis.asyncio import Redis
from app.utils.logger import get_logger
from config import db_settings
logger = get_logger("redis_cache")
# Dashboard 전용 Redis 클라이언트 (db=3 사용)
_cache_client = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=3,
decode_responses=True,
)
async def get_cache(key: str) -> Optional[str]:
"""
Redis 캐시에서 값을 조회합니다.
Args:
key: 캐시
Returns:
캐시된 (문자열) 또는 None (캐시 미스)
Example:
>>> cached_data = await get_cache("dashboard:user123:2026-01-01:2026-12-31")
>>> if cached_data:
>>> return json.loads(cached_data)
"""
try:
logger.debug(f"[GET_CACHE] 캐시 조회 시작 - key: {key}")
value = await _cache_client.get(key)
if value:
logger.debug(f"[GET_CACHE] 캐시 HIT - key: {key}")
else:
logger.debug(f"[GET_CACHE] 캐시 MISS - key: {key}")
return value
except Exception as e:
logger.error(f"[GET_CACHE] 캐시 조회 실패 - key: {key}, error: {e}")
return None # 캐시 실패 시 None 반환 (원본 데이터 조회하도록 유도)
async def set_cache(key: str, value: str, ttl: int = 43200) -> bool:
"""
Redis 캐시에 값을 저장합니다.
Args:
key: 캐시
value: 저장할 (문자열)
ttl: 캐시 만료 시간 (). 기본값: 43200 (12시간)
Returns:
성공 여부
Example:
>>> import json
>>> data = {"views": 1000, "likes": 50}
>>> await set_cache("dashboard:user123:2026-01-01:2026-12-31", json.dumps(data), ttl=3600)
"""
try:
logger.debug(f"[SET_CACHE] 캐시 저장 시작 - key: {key}, ttl: {ttl}s")
await _cache_client.setex(key, ttl, value)
logger.debug(f"[SET_CACHE] 캐시 저장 성공 - key: {key}")
return True
except Exception as e:
logger.error(f"[SET_CACHE] 캐시 저장 실패 - key: {key}, error: {e}")
return False
async def delete_cache(key: str) -> bool:
"""
Redis 캐시에서 값을 삭제합니다.
Args:
key: 삭제할 캐시
Returns:
성공 여부
Example:
>>> await delete_cache("dashboard:user123:2026-01-01:2026-12-31")
"""
try:
logger.debug(f"[DELETE_CACHE] 캐시 삭제 시작 - key: {key}")
deleted_count = await _cache_client.delete(key)
logger.debug(
f"[DELETE_CACHE] 캐시 삭제 완료 - key: {key}, deleted: {deleted_count}"
)
return deleted_count > 0
except Exception as e:
logger.error(f"[DELETE_CACHE] 캐시 삭제 실패 - key: {key}, error: {e}")
return False
async def delete_cache_pattern(pattern: str) -> int:
"""
패턴에 매칭되는 모든 캐시 키를 삭제합니다.
Args:
pattern: 삭제할 패턴 (: "dashboard:user123:*")
Returns:
삭제된 개수
Example:
>>> # 특정 사용자의 모든 대시보드 캐시 삭제
>>> deleted = await delete_cache_pattern("dashboard:user123:*")
>>> print(f"{deleted}개의 캐시 삭제됨")
Note:
대량의 삭제 성능에 영향을 있으므로 주의해서 사용하세요.
"""
try:
logger.debug(f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 시작 - pattern: {pattern}")
# 패턴에 매칭되는 모든 키 조회
keys = []
async for key in _cache_client.scan_iter(match=pattern):
keys.append(key)
if not keys:
logger.debug(f"[DELETE_CACHE_PATTERN] 삭제할 키 없음 - pattern: {pattern}")
return 0
# 모든 키 삭제
deleted_count = await _cache_client.delete(*keys)
logger.debug(
f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 완료 - "
f"pattern: {pattern}, deleted: {deleted_count}"
)
return deleted_count
except Exception as e:
logger.error(
f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}"
)
return 0
async def close_cache_client():
"""
Redis 클라이언트 연결을 종료합니다.
애플리케이션 종료 호출되어야 합니다.
main.py의 shutdown 이벤트 핸들러에서 사용하세요.
Example:
>>> # main.py
>>> @app.on_event("shutdown")
>>> async def shutdown_event():
>>> await close_cache_client()
"""
try:
logger.info("[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 중...")
await _cache_client.close()
logger.info("[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 완료")
except Exception as e:
logger.error(
f"[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 실패 - error: {e}"
)

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

@ -6,7 +6,6 @@ from sqlalchemy.orm import DeclarativeBase
from app.utils.logger import get_logger from app.utils.logger import get_logger
from config import db_settings from config import db_settings
import traceback
logger = get_logger("database") logger = get_logger("database")
@ -74,13 +73,10 @@ async def create_db_tables():
# 모델 import (테이블 메타데이터 등록용) # 모델 import (테이블 메타데이터 등록용)
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401 from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401 from app.home.models import Image, Project # 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, SongTimestamp # 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
from app.dashboard.models import Dashboard # noqa: F401
# 생성할 테이블 목록 # 생성할 테이블 목록
tables_to_create = [ tables_to_create = [
@ -93,11 +89,6 @@ async def create_db_tables():
Song.__table__, Song.__table__,
SongTimestamp.__table__, SongTimestamp.__table__,
Video.__table__, Video.__table__,
SNSUploadTask.__table__,
SocialUpload.__table__,
MarketingIntel.__table__,
Dashboard.__table__,
ImageTag.__table__,
] ]
logger.info("Creating database tables...") logger.info("Creating database tables...")
@ -130,9 +121,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
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"
@ -172,7 +161,6 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
f"error: {type(e).__name__}: {e}, " f"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"
) )
logger.debug(traceback.format_exc())
raise e raise e
finally: finally:
total_time = time.perf_counter() - start_time total_time = time.perf_counter() - start_time

View File

@ -9,10 +9,9 @@ import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, select
from app.database.session import get_session, AsyncSessionLocal from app.database.session import get_session, AsyncSessionLocal
from app.home.models import Image, MarketingIntel, ImageTag from app.home.models import Image
from app.user.dependencies.auth import get_current_user from app.user.dependencies.auth import get_current_user
from app.user.models import User from app.user.models import User
from app.home.schemas.home_schema import ( from app.home.schemas.home_schema import (
@ -30,13 +29,12 @@ from app.home.schemas.home_schema import (
) )
from app.home.services.naver_search import naver_search_client 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.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
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, URLNotFoundException from app.utils.nvMapScraper import NvMapScraper, GraphQLException
from app.utils.nvMapPwScraper import NvMapPwScraper from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt from app.utils.prompts.prompts import marketing_prompt
from app.utils.autotag import autotag_images
from config import MEDIA_ROOT from config import MEDIA_ROOT
# 로거 설정 # 로거 설정
@ -155,10 +153,8 @@ 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, return await _crawling_logic(request_body.url)
session: AsyncSession = Depends(get_session)):
return await _crawling_logic(request_body.url, session)
@router.post( @router.post(
"/autocomplete", "/autocomplete",
@ -191,15 +187,11 @@ async def crawling(
}, },
tags=["Crawling"], tags=["Crawling"],
) )
async def autocomplete_crawling( async def autocomplete_crawling(request_body: AutoCompleteRequest):
request_body: AutoCompleteRequest, url = await _autocomplete_logic(request_body.dict())
session: AsyncSession = Depends(get_session)): return await _crawling_logic(url)
url = await _autocomplete_logic(request_body.model_dump())
return await _crawling_logic(url, session)
async def _crawling_logic( async def _crawling_logic(url:str):
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: {url[:80]}...")
@ -220,15 +212,6 @@ async def _crawling_logic(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"네이버 지도 크롤링에 실패했습니다: {e}", detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
) )
except URLNotFoundException as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(
f"[crawling] Step 1 FAILED - 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Place ID를 확인할 수 없습니다. URL을 확인하세요. : {e}",
)
except Exception as e: except Exception as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error( logger.error(
@ -298,15 +281,6 @@ async def _crawling_logic(
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)"
@ -386,7 +360,6 @@ async def _crawling_logic(
"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
} }
@ -402,7 +375,7 @@ async def _autocomplete_logic(autocomplete_item:dict):
) )
logger.exception("[crawling] Autocomplete 상세 오류:") logger.exception("[crawling] Autocomplete 상세 오류:")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_502_BAD_GATEWAY,
detail="자동완성 place id 추출 실패", detail="자동완성 place id 추출 실패",
) )
@ -462,6 +435,255 @@ IMAGES_JSON_EXAMPLE = """[
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"} {"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
]""" ]"""
@router.post(
"/image/upload/server",
include_in_schema=False,
summary="이미지 업로드 (로컬 서버)",
description="""
이미지를 로컬 서버(media 폴더) 업로드하고 새로운 task_id를 생성합니다.
## 요청 방식
multipart/form-data 형식으로 전송합니다.
## 요청 필드
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
- **files**: 이미지 바이너리 파일 목록 (선택)
**주의**: images_json 또는 files 최소 하나는 반드시 전달해야 합니다.
## 지원 이미지 확장자
jpg, jpeg, png, webp, heic, heif
## images_json 예시
```json
[
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
]
```
## 바이너리 파일 업로드 테스트 방법
### 1. Swagger UI에서 테스트
1. 엔드포인트의 "Try it out" 버튼 클릭
2. task_id 입력 (: test-task-001)
3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택
4. (선택) images_json에 URL 목록 JSON 입력
5. "Execute" 버튼 클릭
### 2. cURL로 테스트
```bash
# 바이너리 파일만 업로드
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
-F "files=@/path/to/image1.jpg" \\
-F "files=@/path/to/image2.png"
# URL + 바이너리 파일 동시 업로드
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
-F "files=@/path/to/local_image.jpg"
```
### 3. Python requests로 테스트
```python
import requests
url = "http://localhost:8000/image/upload/server/test-task-001"
files = [
("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")),
("files", ("image2.png", open("image2.png", "rb"), "image/png")),
]
data = {
"images_json": '[{"url": "https://example.com/image.jpg"}]'
}
response = requests.post(url, files=files, data=data)
print(response.json())
```
## 반환 정보
- **task_id**: 작업 고유 식별자
- **total_count**: 업로드된 이미지 개수
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
- **file_count**: 파일로 업로드된 이미지 개수 (media 폴더에 저장)
- **saved_count**: Image 테이블에 저장된 row
- **images**: 업로드된 이미지 목록
- **source**: "url" (외부 URL) 또는 "file" (로컬 서버 저장)
## 저장 경로
- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명}
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
## 반환 정보
- **task_id**: 새로 생성된 작업 고유 식별자
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
""",
response_model=ImageUploadResponse,
responses={
200: {"description": "이미지 업로드 성공"},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
},
tags=["Image-Server"],
)
async def upload_images(
images_json: Optional[str] = Form(
default=None,
description="외부 이미지 URL 목록 (JSON 문자열)",
examples=[IMAGES_JSON_EXAMPLE],
),
files: Optional[list[UploadFile]] = File(
default=None, description="이미지 바이너리 파일 목록"
),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ImageUploadResponse:
"""이미지 업로드 (URL + 바이너리 파일)"""
# task_id 생성
task_id = await generate_task_id()
# 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함
has_images_json = images_json is not None and images_json.strip() != ""
has_files = files is not None and len(files) > 0
if not has_images_json and not has_files:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
)
# 2. images_json 파싱 (있는 경우만)
url_images: list[ImageUrlItem] = []
if has_images_json:
try:
parsed = json.loads(images_json)
if isinstance(parsed, list):
url_images = [ImageUrlItem(**item) for item in parsed if item]
except (json.JSONDecodeError, TypeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"images_json 파싱 오류: {str(e)}",
)
# 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외)
valid_files: list[UploadFile] = []
skipped_files: list[str] = []
if has_files and files:
for f in files:
is_valid_ext = _is_valid_image_extension(f.filename)
is_not_empty = (
f.size is None or f.size > 0
) # size가 None이면 아직 읽지 않은 것
is_real_file = (
f.filename and f.filename != "filename"
) # Swagger 빈 파일 체크
if f and is_real_file and is_valid_ext and is_not_empty:
valid_files.append(f)
else:
skipped_files.append(f.filename or "unknown")
# 유효한 데이터가 하나도 없으면 에러
if not url_images and not valid_files:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}",
)
result_images: list[ImageUploadResultItem] = []
img_order = 0
# 1. URL 이미지 저장
for url_item in url_images:
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
image = Image(
task_id=task_id,
img_name=img_name,
img_url=url_item.url,
img_order=img_order,
)
session.add(image)
await session.flush() # ID 생성을 위해 flush
result_images.append(
ImageUploadResultItem(
id=image.id,
img_name=img_name,
img_url=url_item.url,
img_order=img_order,
source="url",
)
)
img_order += 1
# 2. 바이너리 파일을 media에 저장
if valid_files:
today = date.today().strftime("%Y-%m-%d")
# 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장
batch_uuid = await generate_task_id()
upload_dir = MEDIA_ROOT / "image" / today / batch_uuid
upload_dir.mkdir(parents=True, exist_ok=True)
for file in valid_files:
# 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가)
original_name = file.filename or "image"
ext = _get_file_extension(file.filename) # type: ignore[arg-type]
# 파일명에서 확장자 제거 후 순서 추가
name_without_ext = (
original_name.rsplit(".", 1)[0]
if "." in original_name
else original_name
)
filename = f"{name_without_ext}_{img_order:03d}{ext}"
save_path = upload_dir / filename
# media에 파일 저장
await _save_upload_file(file, save_path)
# media 기준 URL 생성
img_url = f"/media/image/{today}/{batch_uuid}/{filename}"
img_name = file.filename or filename
image = Image(
task_id=task_id,
img_name=img_name,
img_url=img_url, # Media URL을 DB에 저장
img_order=img_order,
)
session.add(image)
await session.flush()
result_images.append(
ImageUploadResultItem(
id=image.id,
img_name=img_name,
img_url=img_url,
img_order=img_order,
source="file",
)
)
img_order += 1
saved_count = len(result_images)
await session.commit()
# Image 테이블에서 현재 task_id의 이미지 URL 목록 조회
image_urls = [img.img_url for img in result_images]
return ImageUploadResponse(
task_id=task_id,
total_count=len(result_images),
url_count=len(url_images),
file_count=len(valid_files),
saved_count=saved_count,
images=result_images,
image_urls=image_urls,
)
@router.post( @router.post(
"/image/upload/blob", "/image/upload/blob",
summary="이미지 업로드 (Azure Blob Storage)", summary="이미지 업로드 (Azure Blob Storage)",
@ -750,10 +972,6 @@ async def upload_images_blob(
saved_count = len(result_images) saved_count = len(result_images)
image_urls = [img.img_url for img in result_images] image_urls = [img.img_url for img in result_images]
logger.info(f"[image_tagging] START - task_id: {task_id}")
await tag_images_if_not_exist(image_urls)
logger.info(f"[image_tagging] Done - task_id: {task_id}")
total_time = time.perf_counter() - request_start total_time = time.perf_counter() - request_start
logger.info( logger.info(
f"[upload_images_blob] SUCCESS - task_id: {task_id}, " f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
@ -769,36 +987,3 @@ async def upload_images_blob(
images=result_images, images=result_images,
image_urls=image_urls, image_urls=image_urls,
) )
async def tag_images_if_not_exist(
image_urls : list[str]
) -> None:
# 1. 조회
async with AsyncSessionLocal() as session:
stmt = (
select(ImageTag)
.where(ImageTag.img_url_hash.in_([func.crc32(url) for url in image_urls]))
.where(ImageTag.img_url.in_(image_urls))
)
image_tags_query_results = await session.execute(stmt)
image_tags = image_tags_query_results.scalars().all()
existing_urls = {tag.img_url for tag in image_tags}
new_tags = [
ImageTag(img_url=url, img_tag=None)
for url in image_urls
if url not in existing_urls
]
session.add_all(new_tags)
null_tags = [tag for tag in image_tags if tag.img_tag is None] + new_tags
if null_tags:
tag_datas = await autotag_images([img.img_url for img in null_tags])
print(tag_datas)
for tag, tag_data in zip(null_tags, tag_datas):
tag.img_tag = tag_data.model_dump(mode="json")
await session.commit()

View File

@ -7,10 +7,9 @@ Home 모듈 SQLAlchemy 모델 정의
""" """
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, Computed, Index, Integer, String, Text, JSON, func from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.mysql import INTEGER
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
@ -108,12 +107,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,
@ -256,109 +249,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="마케팅 인텔리전스 결과물",
)
subtitle : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
comment="자막 정보 생성 결과물",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
def __repr__(self) -> str:
return (
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>"
)
class ImageTag(Base):
"""
이미지 태그 테이블
"""
__tablename__ = "image_tags"
__table_args__ = (
Index("idx_img_url_hash", "img_url_hash"), # CRC32 index
{
"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="고유 식별자",
)
img_url: Mapped[str] = mapped_column(
String(2048),
nullable=False,
comment="이미지 URL (blob, CDN 경로)",
)
img_url_hash: Mapped[int] = mapped_column(
INTEGER(unsigned=True),
Computed("CRC32(img_url)", persisted=True), # generated column
comment="URL CRC32 해시 (검색용 index)",
)
img_tag: Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
default=False,
comment="태그 JSON",
)

View File

@ -3,6 +3,112 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.utils.prompts.schemas import MarketingPromptOutput 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):
"""크롤링 요청 스키마""" """크롤링 요청 스키마"""
@ -169,44 +275,37 @@ class CrawlingResponse(BaseModel):
], ],
"selling_points": [ "selling_points": [
{ {
"english_category": "LOCATION", "category": "LOCATION",
"korean_category": "입지 환경",
"description": "군산 감성 동선", "description": "군산 감성 동선",
"score": 88 "score": 88
}, },
{ {
"english_category": "HEALING", "category": "HEALING",
"korean_category": "힐링 요소",
"description": "멈춤이 되는 쉼", "description": "멈춤이 되는 쉼",
"score": 92 "score": 92
}, },
{ {
"english_category": "PRIVACY", "category": "PRIVACY",
"korean_category": "프라이버시",
"description": "방해 없는 머뭄", "description": "방해 없는 머뭄",
"score": 86 "score": 86
}, },
{ {
"english_category": "NIGHT MOOD", "category": "NIGHT MOOD",
"korean_category": "야간 감성",
"description": "밤이 예쁜 조명", "description": "밤이 예쁜 조명",
"score": 84 "score": 84
}, },
{ {
"english_category": "PHOTO SPOT", "category": "PHOTO SPOT",
"korean_category": "포토 스팟",
"description": "자연광 포토존", "description": "자연광 포토존",
"score": 83 "score": 83
}, },
{ {
"english_category": "SHORT GETAWAY", "category": "SHORT GETAWAY",
"korean_category": "숏브레이크",
"description": "주말 리셋 스테이", "description": "주말 리셋 스테이",
"score": 89 "score": 89
}, },
{ {
"english_category": "HOSPITALITY", "category": "HOSPITALITY",
"korean_category": "서비스",
"description": "세심한 웰컴감", "description": "세심한 웰컴감",
"score": 80 "score": 80
} }
@ -223,8 +322,7 @@ class CrawlingResponse(BaseModel):
"힐링스테이", "힐링스테이",
"스테이머뭄" "스테이머뭄"
] ]
}, }
"m_id" : 1
} }
} }
) )
@ -241,7 +339,6 @@ class CrawlingResponse(BaseModel):
marketing_analysis: Optional[MarketingPromptOutput] = Field( marketing_analysis: Optional[MarketingPromptOutput] = Field(
None, description="마케팅 분석 결과 . 실패 시 null" None, description="마케팅 분석 결과 . 실패 시 null"
) )
m_id : int = Field(..., description="마케팅 분석 결과 ID")
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
@ -265,6 +362,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

@ -30,7 +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.dependencies.auth import get_current_user
from app.user.models import User from app.user.models import User
from app.lyric.models import Lyric from app.lyric.models import Lyric
@ -41,14 +41,13 @@ from app.lyric.schemas.lyric import (
LyricListItem, LyricListItem,
LyricStatusResponse, LyricStatusResponse,
) )
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background from app.lyric.worker.lyric_task import generate_lyric_background
from app.utils.prompts.chatgpt_prompt import ChatgptService from app.utils.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse, get_paginated 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")
@ -253,6 +252,17 @@ async def generate_lyric(
step1_start = time.perf_counter() step1_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
# service = ChatgptService(
# customer_name=request_body.customer_name,
# region=request_body.region,
# detail_region_info=request_body.detail_region_info or "",
# language=request_body.language,
# )
# prompt = service.build_lyrics_prompt()
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
promotional_expressions = { promotional_expressions = {
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소", "Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway", "English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
@ -268,19 +278,16 @@ 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)")
@ -307,7 +314,6 @@ async def generate_lyric(
detail_region_info=request_body.detail_region_info, detail_region_info=request_body.detail_region_info,
language=request_body.language, language=request_body.language,
user_uuid=current_user.user_uuid, user_uuid=current_user.user_uuid,
marketing_intelligence = request_body.m_id
) )
session.add(project) session.add(project)
await session.commit() await session.commit()
@ -340,7 +346,7 @@ async def generate_lyric(
# ========== Step 4: 백그라운드 태스크 스케줄링 ========== # ========== Step 4: 백그라운드 태스크 스케줄링 ==========
step4_start = time.perf_counter() step4_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...") logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
orientation = request_body.orientation
background_tasks.add_task( background_tasks.add_task(
generate_lyric_background, generate_lyric_background,
task_id=task_id, task_id=task_id,
@ -349,12 +355,6 @@ async def generate_lyric(
lyric_id=lyric.id, lyric_id=lyric.id,
) )
background_tasks.add_task(
generate_subtitle_background,
orientation = orientation,
task_id=task_id
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step4_elapsed = (time.perf_counter() - step4_start) * 1000
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)") logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")

View File

@ -23,7 +23,7 @@ Lyric API Schemas
""" """
from datetime import datetime from datetime import datetime
from typing import Optional, Literal from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -41,9 +41,7 @@ class GenerateLyricRequest(BaseModel):
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean"
"m_id" : 2,
"orientation" : "vertical"
} }
""" """
@ -55,8 +53,6 @@ class GenerateLyricRequest(BaseModel):
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean",
"m_id" : 1,
"orientation" : "vertical"
} }
} }
) )
@ -70,12 +66,7 @@ class GenerateLyricRequest(BaseModel):
language: str = Field( language: str = Field(
default="Korean", default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
), )
orientation: Literal["horizontal", "vertical"] = Field(
default="vertical",
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
),
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
class GenerateLyricResponse(BaseModel): class GenerateLyricResponse(BaseModel):

View File

@ -7,15 +7,11 @@ Lyric Background Tasks
import traceback import traceback
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.home.models import Image, Project, MarketingIntel
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils.creatomate import CreatomateService
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
@ -104,6 +100,13 @@ async def generate_lyric_background(
step1_start = time.perf_counter() step1_start = time.perf_counter()
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...") logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
# service = ChatgptService(
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
# region="",
# detail_region_info="",
# language=language,
# )
chatgpt = ChatgptService() chatgpt = ChatgptService()
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
@ -155,55 +158,3 @@ async def generate_lyric_background(
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)}", lyric_id)
async def generate_subtitle_background(
orientation: str,
task_id: str
) -> None:
logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}")
creatomate_service = CreatomateService(orientation=orientation)
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
pitchings = creatomate_service.extract_text_format_from_template(template)
subtitle_generator = SubtitleContentsGenerator()
async with BackgroundSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
store_address = project.detail_region_info
customer_name = project.store_name
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
marketing_intelligence = marketing_intelligence.intel_result,
pitching_label_list = pitchings,
customer_name = customer_name,
detail_region_info = store_address,
)
pitching_output_list = generated_subtitles.pitching_results
subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list}
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
async with BackgroundSessionLocal() as session:
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
marketing_intelligence.subtitle = subtitle_modifications
await session.commit()
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
return

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

@ -4,5 +4,5 @@ Social API Routers v1
from app.social.api.routers.v1.oauth import router as oauth_router 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.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"] __all__ = ["oauth_router", "upload_router"]

View File

@ -1,39 +0,0 @@
"""
내부 전용 소셜 업로드 API
스케줄러 서버에서만 호출하는 내부 엔드포인트입니다.
X-Internal-Secret 헤더로 인증합니다.
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, status
from app.social.worker.upload_task import process_social_upload
from config import internal_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/social", tags=["Internal"])
@router.post(
"/upload/{upload_id}",
summary="[내부] 예약 업로드 실행",
description="스케줄러 서버에서 호출하는 내부 전용 엔드포인트입니다.",
)
async def trigger_scheduled_upload(
upload_id: int,
background_tasks: BackgroundTasks,
x_internal_secret: str = Header(...),
) -> dict:
if x_internal_secret != internal_settings.INTERNAL_SECRET_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid internal secret",
)
logger.info(f"[INTERNAL] 예약 업로드 실행 - upload_id: {upload_id}")
background_tasks.add_task(process_social_upload, upload_id)
return {"success": True, "upload_id": upload_id, "message": "업로드 작업이 시작되었습니다."}

View File

@ -238,7 +238,7 @@ async def get_account_by_platform(
raise SocialAccountNotFoundError(platform=platform.value) raise SocialAccountNotFoundError(platform=platform.value)
return social_account_service.to_response(account) return social_account_service._to_response(account)
@router.delete( @router.delete(

View File

@ -1,37 +0,0 @@
"""
소셜 SEO API 라우터
SEO 관련 엔드포인트를 제공합니다.
비즈니스 로직은 SeoService에 위임합니다.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.social.schemas import YoutubeDescriptionRequest, YoutubeDescriptionResponse
from app.social.services import seo_service
from app.user.dependencies import get_current_user
from app.user.models import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/seo", tags=["Social SEO"])
@router.post(
"/youtube",
response_model=YoutubeDescriptionResponse,
summary="유튜브 SEO description 생성",
description="유튜브 업로드 시 사용할 description을 SEO 적용하여 생성",
)
async def youtube_seo_description(
request_body: YoutubeDescriptionRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> YoutubeDescriptionResponse:
return await seo_service.get_youtube_seo_description(
request_body.task_id, current_user, session
)

View File

@ -2,34 +2,37 @@
소셜 업로드 API 라우터 소셜 업로드 API 라우터
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다. 소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
비즈니스 로직은 SocialUploadService에 위임합니다.
""" """
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Query from fastapi import APIRouter, BackgroundTasks, Depends, Query
from sqlalchemy import select, func
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.social.constants import SocialPlatform 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 ( from app.social.schemas import (
MessageResponse, MessageResponse,
SocialUploadHistoryItem,
SocialUploadHistoryResponse, SocialUploadHistoryResponse,
SocialUploadRequest, SocialUploadRequest,
SocialUploadResponse, SocialUploadResponse,
SocialUploadStatusResponse, SocialUploadStatusResponse,
) )
from app.social.services import SocialUploadService, social_account_service 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.dependencies import get_current_user
from app.user.models import User from app.user.models import User
from app.video.models import Video
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/upload", tags=["Social Upload"]) router = APIRouter(prefix="/upload", tags=["Social Upload"])
upload_service = SocialUploadService(account_service=social_account_service)
@router.post( @router.post(
"", "",
@ -66,7 +69,111 @@ async def upload_to_social(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SocialUploadResponse: ) -> SocialUploadResponse:
return await upload_service.request_upload(body, current_user, session, background_tasks) """
소셜 플랫폼에 영상 업로드 요청
백그라운드에서 영상을 다운로드하고 소셜 플랫폼에 업로드합니다.
"""
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. 기존 업로드 확인 (동일 video + account 조합)
existing_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]
),
)
)
existing_upload = existing_result.scalar_one_or_none()
if existing_upload:
logger.info(
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {existing_upload.id}"
)
return SocialUploadResponse(
success=True,
upload_id=existing_upload.id,
platform=account.platform,
status=existing_upload.status,
message="이미 업로드가 진행 중입니다.",
)
# 4. 새 업로드 레코드 생성
social_upload = SocialUpload(
user_uuid=current_user.user_uuid,
video_id=body.video_id,
social_account_id=account.id,
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}, platform: {account.platform}"
)
# 5. 백그라운드 태스크 등록
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( @router.get(
@ -80,35 +187,116 @@ async def get_upload_status(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SocialUploadStatusResponse: ) -> SocialUploadStatusResponse:
return await upload_service.get_upload_status(upload_id, current_user, session) """
업로드 상태 조회
"""
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,
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( @router.get(
"/history", "/history",
response_model=SocialUploadHistoryResponse, response_model=SocialUploadHistoryResponse,
summary="업로드 이력 조회", summary="업로드 이력 조회",
description=""" description="사용자의 소셜 미디어 업로드 이력을 조회합니다.",
사용자의 소셜 미디어 업로드 이력을 조회합니다.
## tab 파라미터
- `all`: 전체 (기본값)
- `completed`: 완료된 업로드
- `scheduled`: 예약 업로드 (pending + scheduled_at 있음)
- `failed`: 실패한 업로드
""",
) )
async def get_upload_history( async def get_upload_history(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
tab: str = Query("all", description="탭 필터 (all/completed/scheduled/failed)"),
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"), platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
year: Optional[int] = Query(None, description="조회 연도 (없으면 현재 연도)"), status: Optional[UploadStatus] = Query(None, description="상태 필터"),
month: Optional[int] = Query(None, ge=1, le=12, description="조회 월 (없으면 현재 월)"),
page: int = Query(1, ge=1, description="페이지 번호"), page: int = Query(1, ge=1, description="페이지 번호"),
size: int = Query(20, ge=1, le=100, description="페이지 크기"), size: int = Query(20, ge=1, le=100, description="페이지 크기"),
) -> SocialUploadHistoryResponse: ) -> SocialUploadHistoryResponse:
return await upload_service.get_upload_history( """
current_user, session, tab, platform, year, month, page, size 업로드 이력 조회
"""
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,
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,
) )
@ -124,7 +312,53 @@ async def retry_upload(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SocialUploadResponse: ) -> SocialUploadResponse:
return await upload_service.retry_upload(upload_id, current_user, session, background_tasks) """
업로드 재시도
실패한 업로드를 다시 시도합니다.
"""
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( @router.delete(
@ -138,4 +372,42 @@ async def cancel_upload(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> MessageResponse: ) -> MessageResponse:
return await upload_service.cancel_upload(upload_id, current_user, session) """
업로드 취소
대기 중인 업로드를 취소합니다.
이미 진행 중이거나 완료된 업로드는 취소할 없습니다.
"""
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 != UploadStatus.PENDING.value:
from fastapi import HTTPException, status
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

@ -91,12 +91,9 @@ PLATFORM_CONFIG = {
YOUTUBE_SCOPES = [ YOUTUBE_SCOPES = [
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드 "https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기 "https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
"https://www.googleapis.com/auth/yt-analytics.readonly", # 대시보드
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필 "https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
] ]
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
# ============================================================================= # =============================================================================
# Instagram/Facebook OAuth Scopes (추후 구현) # Instagram/Facebook OAuth Scopes (추후 구현)
# ============================================================================= # =============================================================================

View File

@ -123,7 +123,6 @@ class TokenExpiredError(OAuthException):
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_EXPIRED", code="TOKEN_EXPIRED",
) )
self.platform = platform
# ============================================================================= # =============================================================================

View File

@ -29,7 +29,6 @@ class SocialUpload(Base):
user_uuid: 사용자 UUID (User.user_uuid 참조) user_uuid: 사용자 UUID (User.user_uuid 참조)
video_id: Video 외래키 video_id: Video 외래키
social_account_id: SocialAccount 외래키 social_account_id: SocialAccount 외래키
upload_seq: 업로드 순번 (동일 영상+채널 조합 순번, 관리자 추적용)
platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok) platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
status: 업로드 상태 (pending, uploading, processing, completed, failed) status: 업로드 상태 (pending, uploading, processing, completed, failed)
upload_progress: 업로드 진행률 (0-100) upload_progress: 업로드 진행률 (0-100)
@ -59,10 +58,12 @@ class SocialUpload(Base):
Index("idx_social_upload_platform", "platform"), Index("idx_social_upload_platform", "platform"),
Index("idx_social_upload_status", "status"), Index("idx_social_upload_status", "status"),
Index("idx_social_upload_created_at", "created_at"), Index("idx_social_upload_created_at", "created_at"),
# 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능) Index(
Index("idx_social_upload_video_account", "video_id", "social_account_id"), "uq_social_upload_video_platform",
# 순번 조회용 인덱스 "video_id",
Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"), "social_account_id",
unique=True,
),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -105,16 +106,6 @@ class SocialUpload(Base):
comment="SocialAccount 외래키", comment="SocialAccount 외래키",
) )
# ==========================================================================
# 업로드 순번 (관리자 추적용)
# ==========================================================================
upload_seq: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
comment="업로드 순번 (동일 영상+채널 조합 내 순번, 1부터 시작)",
)
# ========================================================================== # ==========================================================================
# 플랫폼 정보 # 플랫폼 정보
# ========================================================================== # ==========================================================================
@ -190,15 +181,6 @@ class SocialUpload(Base):
comment="플랫폼별 추가 옵션 (JSON)", comment="플랫폼별 추가 옵션 (JSON)",
) )
# ==========================================================================
# 예약 게시 시간
# ==========================================================================
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="예약 게시 시간 (스케줄러가 이 시간 이후에 업로드 실행)",
)
# ========================================================================== # ==========================================================================
# 에러 정보 # 에러 정보
# ========================================================================== # ==========================================================================
@ -256,10 +238,8 @@ class SocialUpload(Base):
return ( return (
f"<SocialUpload(" f"<SocialUpload("
f"id={self.id}, " 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"platform='{self.platform}', "
f"status='{self.status}'" f"status='{self.status}', "
f"video_id={self.video_id}"
f")>" f")>"
) )

View File

@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient):
"response_type": "code", "response_type": "code",
"scope": " ".join(YOUTUBE_SCOPES), "scope": " ".join(YOUTUBE_SCOPES),
"access_type": "offline", # refresh_token 받기 위해 필요 "access_type": "offline", # refresh_token 받기 위해 필요
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만) "prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지)
"state": state, "state": state,
} }
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}" url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"

View File

@ -1,5 +1,7 @@
""" """
소셜 업로드 관련 Pydantic 스키마 Social Media Schemas
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
""" """
from datetime import datetime from datetime import datetime
@ -7,7 +9,123 @@ from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.social.constants import PrivacyStatus, UploadStatus 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): class SocialUploadRequest(BaseModel):
@ -41,7 +159,7 @@ class SocialUploadRequest(BaseModel):
"privacy_status": "public", "privacy_status": "public",
"scheduled_at": "2026-02-02T15:00:00", "scheduled_at": "2026-02-02T15:00:00",
"platform_options": { "platform_options": {
"category_id": "22", "category_id": "22", # YouTube 카테고리
}, },
} }
} }
@ -75,8 +193,6 @@ class SocialUploadStatusResponse(BaseModel):
upload_id: int = Field(..., description="업로드 작업 ID") upload_id: int = Field(..., description="업로드 작업 ID")
video_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="플랫폼명") platform: str = Field(..., description="플랫폼명")
status: UploadStatus = Field(..., description="업로드 상태") status: UploadStatus = Field(..., description="업로드 상태")
upload_progress: int = Field(..., description="업로드 진행률 (0-100)") upload_progress: int = Field(..., description="업로드 진행률 (0-100)")
@ -85,7 +201,6 @@ class SocialUploadStatusResponse(BaseModel):
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지") error_message: Optional[str] = Field(None, description="에러 메시지")
retry_count: int = Field(default=0, description="재시도 횟수") retry_count: int = Field(default=0, description="재시도 횟수")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간 (있으면 예약 업로드)")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
@ -95,8 +210,6 @@ class SocialUploadStatusResponse(BaseModel):
"example": { "example": {
"upload_id": 456, "upload_id": 456,
"video_id": 123, "video_id": 123,
"social_account_id": 1,
"upload_seq": 2,
"platform": "youtube", "platform": "youtube",
"status": "completed", "status": "completed",
"upload_progress": 100, "upload_progress": 100,
@ -117,14 +230,10 @@ class SocialUploadHistoryItem(BaseModel):
upload_id: int = Field(..., description="업로드 작업 ID") upload_id: int = Field(..., description="업로드 작업 ID")
video_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="플랫폼명") platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태") status: str = Field(..., description="업로드 상태")
title: str = Field(..., description="영상 제목") title: str = Field(..., description="영상 제목")
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
@ -160,3 +269,24 @@ class SocialUploadHistoryResponse(BaseModel):
} }
} }
) )
# =============================================================================
# 공통 응답 스키마
# =============================================================================
class MessageResponse(BaseModel):
"""단순 메시지 응답"""
success: bool = Field(..., description="성공 여부")
message: str = Field(..., description="응답 메시지")
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "작업이 완료되었습니다.",
}
}
)

View File

@ -1,35 +0,0 @@
from app.social.schemas.oauth_schema import (
SocialConnectResponse,
SocialAccountResponse,
SocialAccountListResponse,
OAuthTokenResponse,
PlatformUserInfo,
MessageResponse,
)
from app.social.schemas.upload_schema import (
SocialUploadRequest,
SocialUploadResponse,
SocialUploadStatusResponse,
SocialUploadHistoryItem,
SocialUploadHistoryResponse,
)
from app.social.schemas.seo_schema import (
YoutubeDescriptionRequest,
YoutubeDescriptionResponse,
)
__all__ = [
"SocialConnectResponse",
"SocialAccountResponse",
"SocialAccountListResponse",
"OAuthTokenResponse",
"PlatformUserInfo",
"MessageResponse",
"SocialUploadRequest",
"SocialUploadResponse",
"SocialUploadStatusResponse",
"SocialUploadHistoryItem",
"SocialUploadHistoryResponse",
"YoutubeDescriptionRequest",
"YoutubeDescriptionResponse",
]

View File

@ -1,125 +0,0 @@
"""
소셜 OAuth 관련 Pydantic 스키마
"""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
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,
}
}
)
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 MessageResponse(BaseModel):
"""단순 메시지 응답"""
success: bool = Field(..., description="성공 여부")
message: str = Field(..., description="응답 메시지")
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "작업이 완료되었습니다.",
}
}
)

View File

@ -1,37 +0,0 @@
"""
소셜 SEO 관련 Pydantic 스키마
"""
from pydantic import BaseModel, ConfigDict, Field
class YoutubeDescriptionRequest(BaseModel):
"""유튜브 SEO Description 제안 요청"""
task_id: str = Field(..., description="작업 고유 식별자")
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e"
}
}
)
class YoutubeDescriptionResponse(BaseModel):
"""유튜브 SEO Description 제안 응답"""
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": ["여기에", "더미", "해시태그"]
}
}
)

View File

@ -4,15 +4,12 @@ Social Account Service
소셜 계정 연동 관련 비즈니스 로직을 처리합니다. 소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
""" """
import json
import logging import logging
import secrets import secrets
from datetime import timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from app.utils.timezone import now
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio import Redis from redis.asyncio import Redis
@ -28,10 +25,10 @@ redis_client = Redis(
decode_responses=True, decode_responses=True,
) )
from app.social.exceptions import ( from app.social.exceptions import (
InvalidStateError,
OAuthStateExpiredError, OAuthStateExpiredError,
OAuthTokenRefreshError, SocialAccountAlreadyConnectedError,
SocialAccountNotFoundError, SocialAccountNotFoundError,
TokenExpiredError,
) )
from app.social.oauth import get_oauth_client from app.social.oauth import get_oauth_client
from app.social.schemas import ( from app.social.schemas import (
@ -89,7 +86,7 @@ class SocialAccountService:
await redis_client.setex( await redis_client.setex(
state_key, state_key,
social_oauth_settings.OAUTH_STATE_TTL_SECONDS, social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
json.dumps(state_data), # JSON으로 직렬화 str(state_data),
) )
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}") logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
@ -125,7 +122,9 @@ class SocialAccountService:
SocialAccountResponse: 연동된 소셜 계정 정보 SocialAccountResponse: 연동된 소셜 계정 정보
Raises: Raises:
OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우 InvalidStateError: state 토큰이 유효하지 않은 경우
OAuthStateExpiredError: state 토큰이 만료된 경우
SocialAccountAlreadyConnectedError: 이미 연동된 계정인 경우
""" """
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...") logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
@ -137,8 +136,8 @@ class SocialAccountService:
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...") logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
raise OAuthStateExpiredError() raise OAuthStateExpiredError()
# state 데이터 파싱 (JSON 역직렬화) # state 데이터 파싱
state_data = json.loads(state_data_str) state_data = eval(state_data_str) # {"user_uuid": "...", "platform": "..."}
user_uuid = state_data["user_uuid"] user_uuid = state_data["user_uuid"]
platform = SocialPlatform(state_data["platform"]) platform = SocialPlatform(state_data["platform"])
@ -188,7 +187,7 @@ class SocialAccountService:
session=session, session=session,
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트 update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
) )
return self.to_response(existing_account) return self._to_response(existing_account)
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만) # 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
social_account = await self._create_social_account( social_account = await self._create_social_account(
@ -204,21 +203,19 @@ class SocialAccountService:
f"account_id: {social_account.id}, platform: {platform.value}" f"account_id: {social_account.id}, platform: {platform.value}"
) )
return self.to_response(social_account) return self._to_response(social_account)
async def get_connected_accounts( async def get_connected_accounts(
self, self,
user_uuid: str, user_uuid: str,
session: AsyncSession, session: AsyncSession,
auto_refresh: bool = True,
) -> list[SocialAccountResponse]: ) -> list[SocialAccountResponse]:
""" """
연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함) 연동된 소셜 계정 목록 조회
Args: Args:
user_uuid: 사용자 UUID user_uuid: 사용자 UUID
session: DB 세션 session: DB 세션
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
Returns: Returns:
list[SocialAccountResponse]: 연동된 계정 목록 list[SocialAccountResponse]: 연동된 계정 목록
@ -236,96 +233,7 @@ class SocialAccountService:
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨") logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
# 토큰 자동 갱신 return [self._to_response(account) for account in 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( async def get_account_by_platform(
self, self,
@ -494,38 +402,18 @@ class SocialAccountService:
Returns: Returns:
str: 유효한 access_token str: 유효한 access_token
Raises:
TokenExpiredError: 토큰 갱신 실패 (재연동 필요)
""" """
# 만료 시간 확인 # 만료 시간 확인 (만료 10분 전이면 갱신)
is_expired = False if account.token_expires_at:
if account.token_expires_at is None: buffer_time = datetime.now() + timedelta(minutes=10)
is_expired = True
else:
current_time = now().replace(tzinfo=None)
buffer_time = current_time + timedelta(minutes=10)
if account.token_expires_at <= buffer_time: 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( logger.info(
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
) )
return await self._refresh_account_token(account, session) return await self._refresh_account_token(account, session)
return account.access_token
async def _refresh_account_token( async def _refresh_account_token(
self, self,
account: SocialAccount, account: SocialAccount,
@ -540,46 +428,28 @@ class SocialAccountService:
Returns: Returns:
str: access_token str: access_token
Raises:
TokenExpiredError: 갱신 실패 (재연동 필요)
""" """
if not account.refresh_token: if not account.refresh_token:
logger.warning( logger.warning(
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}" f"[SOCIAL] refresh_token 없음, 갱신 불가 - account_id: {account.id}"
) )
raise TokenExpiredError(platform=account.platform) return account.access_token
platform = SocialPlatform(account.platform) platform = SocialPlatform(account.platform)
oauth_client = get_oauth_client(platform) oauth_client = get_oauth_client(platform)
try:
token_response = await oauth_client.refresh_token(account.refresh_token) 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 account.access_token = token_response.access_token
if token_response.refresh_token: if token_response.refresh_token:
account.refresh_token = token_response.refresh_token account.refresh_token = token_response.refresh_token
if token_response.expires_in: if token_response.expires_in:
# DB에 naive datetime으로 저장 (MySQL DateTime은 timezone 미지원) account.token_expires_at = datetime.now() + timedelta(
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
await session.commit() await session.commit()
await session.refresh(account)
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}") logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
return account.access_token return account.access_token
@ -633,10 +503,10 @@ class SocialAccountService:
Returns: Returns:
SocialAccount: 생성된 소셜 계정 SocialAccount: 생성된 소셜 계정
""" """
# 토큰 만료 시간 계산 (DB에 naive datetime으로 저장) # 토큰 만료 시간 계산
token_expires_at = None token_expires_at = None
if token_response.expires_in: if token_response.expires_in:
token_expires_at = now().replace(tzinfo=None) + timedelta( token_expires_at = datetime.now() + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
@ -689,8 +559,7 @@ class SocialAccountService:
if token_response.refresh_token: if token_response.refresh_token:
account.refresh_token = token_response.refresh_token account.refresh_token = token_response.refresh_token
if token_response.expires_in: if token_response.expires_in:
# DB에 naive datetime으로 저장 account.token_expires_at = datetime.now() + timedelta(
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
if token_response.scope: if token_response.scope:
@ -706,14 +575,14 @@ class SocialAccountService:
# 재연결 시 연결 시간 업데이트 # 재연결 시 연결 시간 업데이트
if update_connected_at: if update_connected_at:
account.connected_at = now().replace(tzinfo=None) account.connected_at = datetime.now()
await session.commit() await session.commit()
await session.refresh(account) await session.refresh(account)
return account return account
def to_response(self, account: SocialAccount) -> SocialAccountResponse: def _to_response(self, account: SocialAccount) -> SocialAccountResponse:
""" """
SocialAccount를 SocialAccountResponse로 변환 SocialAccount를 SocialAccountResponse로 변환

View File

@ -1,11 +0,0 @@
from app.social.services.account_service import SocialAccountService, social_account_service
from app.social.services.upload_service import SocialUploadService
from app.social.services.seo_service import SeoService, seo_service
__all__ = [
"SocialAccountService",
"social_account_service",
"SocialUploadService",
"SeoService",
"seo_service",
]

View File

@ -1,12 +0,0 @@
"""
소셜 서비스 베이스 클래스
"""
from sqlalchemy.ext.asyncio import AsyncSession
class BaseService:
"""서비스 레이어 베이스 클래스"""
def __init__(self, session: AsyncSession | None = None):
self.session = session

View File

@ -1,129 +0,0 @@
"""
유튜브 SEO 서비스
SEO description 생성 Redis 캐싱 로직을 처리합니다.
"""
import json
import logging
from fastapi import HTTPException
from redis.asyncio import Redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import db_settings
from app.home.models import MarketingIntel, Project
from app.social.constants import YOUTUBE_SEO_HASH
from app.social.schemas import YoutubeDescriptionResponse
from app.user.models import User
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import yt_upload_prompt
logger = logging.getLogger(__name__)
redis_seo_client = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=0,
decode_responses=True,
)
class SeoService:
"""유튜브 SEO 비즈니스 로직 서비스"""
async def get_youtube_seo_description(
self,
task_id: str,
current_user: User,
session: AsyncSession,
) -> YoutubeDescriptionResponse:
"""
유튜브 SEO description 생성
Redis 캐시 확인 miss이면 GPT로 생성하고 캐싱.
"""
logger.info(
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / task_id: {task_id}"
)
cached = await self._get_from_redis(task_id)
if cached:
return cached
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
result = await self._generate_seo_description(task_id, current_user, session)
await self._set_to_redis(task_id, result)
return result
async def _generate_seo_description(
self,
task_id: str,
current_user: User,
session: AsyncSession,
) -> YoutubeDescriptionResponse:
"""GPT를 사용하여 SEO description 생성"""
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}")
try:
project_result = 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_result.scalar_one_or_none()
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.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(timeout=180)
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
return YoutubeDescriptionResponse(
title=yt_seo_output.title,
description=yt_seo_output.description,
keywords=hashtags,
)
except Exception as e:
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
)
async def _get_from_redis(self, 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:
return YoutubeDescriptionResponse(**json.loads(yt_seo_info))
return None
async def _set_to_redis(self, 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.hset(YOUTUBE_SEO_HASH, field, yt_seo_info)
await redis_seo_client.expire(YOUTUBE_SEO_HASH, 3600)
seo_service = SeoService()

View File

@ -1,391 +0,0 @@
"""
소셜 업로드 서비스
업로드 요청, 상태 조회, 이력 조회, 재시도, 취소 관련 비즈니스 로직을 처리합니다.
"""
import logging
from calendar import monthrange
from datetime import datetime
from typing import Optional
from fastapi import BackgroundTasks, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import TIMEZONE
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,
SocialUploadResponse,
SocialUploadStatusResponse,
SocialUploadRequest,
)
from app.social.services.account_service import SocialAccountService
from app.social.worker.upload_task import process_social_upload
from app.user.models import User
from app.video.models import Video
logger = logging.getLogger(__name__)
class SocialUploadService:
"""소셜 업로드 비즈니스 로직 서비스"""
def __init__(self, account_service: SocialAccountService):
self._account_service = account_service
async def request_upload(
self,
body: SocialUploadRequest,
current_user: User,
session: AsyncSession,
background_tasks: BackgroundTasks,
) -> SocialUploadResponse:
"""
소셜 플랫폼 업로드 요청
영상 검증, 계정 확인, 중복 확인 업로드 레코드 생성.
즉시 업로드이면 백그라운드 태스크 등록, 예약이면 스케줄러가 처리.
"""
logger.info(
f"[UPLOAD_SERVICE] 업로드 요청 - "
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_SERVICE] 영상 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(video_id=body.video_id)
if not video.result_movie_url:
logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(
video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
# 2. 소셜 계정 조회 및 소유권 검증
account = await self._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_SERVICE] 연동 계정 없음 - "
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_SERVICE] 진행 중인 업로드 존재 - 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. 업로드 순번 계산
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,
)
)
next_seq = (max_seq_result.scalar() or 0) + 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,
scheduled_at=body.scheduled_at,
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_SERVICE] 업로드 레코드 생성 - "
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
)
# 6. 즉시 업로드이면 백그라운드 태스크 등록
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
if not is_scheduled:
background_tasks.add_task(process_social_upload, social_upload.id)
message = "예약 업로드가 등록되었습니다." if is_scheduled else "업로드 요청이 접수되었습니다."
return SocialUploadResponse(
success=True,
upload_id=social_upload.id,
platform=account.platform,
status=social_upload.status,
message=message,
)
async def get_upload_status(
self,
upload_id: int,
current_user: User,
session: AsyncSession,
) -> SocialUploadStatusResponse:
"""업로드 상태 조회"""
logger.info(f"[UPLOAD_SERVICE] 상태 조회 - 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="업로드 정보를 찾을 수 없습니다.",
)
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,
scheduled_at=upload.scheduled_at,
created_at=upload.created_at,
uploaded_at=upload.uploaded_at,
)
async def get_upload_history(
self,
current_user: User,
session: AsyncSession,
tab: str = "all",
platform: Optional[SocialPlatform] = None,
year: Optional[int] = None,
month: Optional[int] = None,
page: int = 1,
size: int = 20,
) -> SocialUploadHistoryResponse:
"""업로드 이력 조회 (탭/년월/플랫폼 필터, 페이지네이션)"""
now_kst = datetime.now(TIMEZONE)
target_year = year or now_kst.year
target_month = month or now_kst.month
logger.info(
f"[UPLOAD_SERVICE] 이력 조회 - "
f"user_uuid: {current_user.user_uuid}, tab: {tab}, "
f"year: {target_year}, month: {target_month}, page: {page}, size: {size}"
)
# 월 범위 계산
last_day = monthrange(target_year, target_month)[1]
month_start = datetime(target_year, target_month, 1, 0, 0, 0)
month_end = datetime(target_year, target_month, last_day, 23, 59, 59)
# 기본 쿼리 (cancelled 제외)
base_conditions = [
SocialUpload.user_uuid == current_user.user_uuid,
SocialUpload.created_at >= month_start,
SocialUpload.created_at <= month_end,
SocialUpload.status != UploadStatus.CANCELLED.value,
]
query = select(SocialUpload).where(*base_conditions)
count_query = select(func.count(SocialUpload.id)).where(*base_conditions)
# 탭 필터 적용
if tab == "completed":
query = query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
count_query = count_query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
elif tab == "scheduled":
query = query.where(
SocialUpload.status == UploadStatus.PENDING.value,
SocialUpload.scheduled_at.isnot(None),
)
count_query = count_query.where(
SocialUpload.status == UploadStatus.PENDING.value,
SocialUpload.scheduled_at.isnot(None),
)
elif tab == "failed":
query = query.where(SocialUpload.status == UploadStatus.FAILED.value)
count_query = count_query.where(SocialUpload.status == UploadStatus.FAILED.value)
# 플랫폼 필터 적용
if platform:
query = query.where(SocialUpload.platform == platform.value)
count_query = count_query.where(SocialUpload.platform == platform.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,
error_message=upload.error_message,
scheduled_at=upload.scheduled_at,
created_at=upload.created_at,
uploaded_at=upload.uploaded_at,
)
for upload in uploads
]
return SocialUploadHistoryResponse(
items=items,
total=total,
page=page,
size=size,
)
async def retry_upload(
self,
upload_id: int,
current_user: User,
session: AsyncSession,
background_tasks: BackgroundTasks,
) -> SocialUploadResponse:
"""실패한 업로드 재시도"""
logger.info(f"[UPLOAD_SERVICE] 재시도 요청 - 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 not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
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="업로드 재시도가 요청되었습니다.",
)
async def cancel_upload(
self,
upload_id: int,
current_user: User,
session: AsyncSession,
) -> MessageResponse:
"""대기 중인 업로드 취소"""
logger.info(f"[UPLOAD_SERVICE] 취소 요청 - 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

@ -7,21 +7,19 @@ Social Upload Background Task
import logging import logging
import os import os
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import aiofiles import aiofiles
from app.utils.timezone import now
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from config import social_upload_settings from config import social_upload_settings
from app.dashboard.tasks import insert_dashboard
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.social.constants import SocialPlatform, UploadStatus from app.social.constants import SocialPlatform, UploadStatus
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError from app.social.exceptions import UploadError, UploadQuotaExceededError
from app.social.models import SocialUpload from app.social.models import SocialUpload
from app.social.services import social_account_service from app.social.services import social_account_service
from app.social.uploader import get_uploader from app.social.uploader import get_uploader
@ -72,7 +70,7 @@ async def _update_upload_status(
if error_message: if error_message:
upload.error_message = error_message upload.error_message = error_message
if status == UploadStatus.COMPLETED: if status == UploadStatus.COMPLETED:
upload.uploaded_at = now().replace(tzinfo=None) upload.uploaded_at = datetime.now()
await session.commit() await session.commit()
logger.info( logger.info(
@ -319,7 +317,6 @@ async def process_social_upload(upload_id: int) -> None:
f"platform_video_id: {result.platform_video_id}, " f"platform_video_id: {result.platform_video_id}, "
f"url: {result.platform_url}" f"url: {result.platform_url}"
) )
await insert_dashboard(upload_id)
else: else:
retry_count = await _increment_retry_count(upload_id) retry_count = await _increment_retry_count(upload_id)
@ -355,17 +352,6 @@ async def process_social_upload(upload_id: int) -> None:
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.", 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: except Exception as e:
logger.error( logger.error(
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - " f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "

View File

@ -415,6 +415,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")
@ -428,11 +435,12 @@ async def get_song_status(
download_and_upload_song_by_suno_task_id, download_and_upload_song_by_suno_task_id,
suno_task_id=song_id, suno_task_id=song_id,
audio_url=audio_url, audio_url=audio_url,
store_name=store_name,
user_uuid=current_user.user_uuid, user_uuid=current_user.user_uuid,
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")

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

@ -7,14 +7,14 @@ from sqlalchemy import Connection, text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.lyric.schemas.lyrics_schema import ( from app.lyrics.schemas.lyrics_schema import (
AttributeData, AttributeData,
PromptTemplateData, PromptTemplateData,
SongFormData, SongFormData,
SongSampleData, SongSampleData,
StoreData, StoreData,
) )
from app.utils.prompts.chatgpt_prompt import chatgpt_api from app.utils.chatgpt_prompt import chatgpt_api
logger = get_logger("song") logger = get_logger("song")

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")
@ -114,23 +118,87 @@ 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,
store_name: str,
user_uuid: str, user_uuid: 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
store_name: 저장할 파일명에 사용할 업체명
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
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 +220,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

View File

@ -6,11 +6,10 @@
import logging import logging
import random import random
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi import APIRouter, Depends, Header, HTTPException, 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 pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@ -23,23 +22,21 @@ 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 RefreshToken, 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 ( from app.user.services.jwt import (
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
decode_token,
get_access_token_expire_seconds, get_access_token_expire_seconds,
get_refresh_token_expires_at, get_refresh_token_expires_at,
get_token_hash, get_token_hash,
) )
from app.social.services import social_account_service
from app.utils.common import generate_uuid from app.utils.common import generate_uuid
@ -143,19 +140,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 +205,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(
@ -287,16 +254,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)
@ -320,15 +282,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)
@ -477,7 +434,7 @@ async def generate_test_token(
session.add(db_refresh_token) session.add(db_refresh_token)
# 마지막 로그인 시간 업데이트 # 마지막 로그인 시간 업데이트
user.last_login_at = now().replace(tzinfo=None) user.last_login_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
logger.info( logger.info(

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

@ -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,28 +48,18 @@ 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_uuid = payload.get("sub")
if user_uuid is None: if user_uuid is None:
logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}")
raise InvalidTokenError() raise InvalidTokenError()
# 사용자 조회 # 사용자 조회
@ -84,18 +72,11 @@ async def get_current_user(
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,24 +97,17 @@ 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_uuid = payload.get("sub")
if user_uuid is None: if user_uuid is None:
logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음")
return None return None
result = await session.execute( result = await session.execute(
@ -145,14 +119,8 @@ async def get_current_user_optional(
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,7 +13,6 @@ 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 Project
@ -344,6 +342,7 @@ class RefreshToken(Base):
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 +390,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 +403,6 @@ class RefreshToken(Base):
) )
class Platform(str, Enum):
"""소셜 플랫폼 구분"""
YOUTUBE = "youtube"
INSTAGRAM = "instagram"
FACEBOOK = "facebook"
TIKTOK = "tiktok"
class SocialAccount(Base): class SocialAccount(Base):
""" """
소셜 계정 연동 테이블 소셜 계정 연동 테이블
@ -486,10 +475,10 @@ class SocialAccount(Base):
# ========================================================================== # ==========================================================================
# 플랫폼 구분 # 플랫폼 구분
# ========================================================================== # ==========================================================================
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 +511,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,7 +539,7 @@ class SocialAccount(Base):
Boolean, Boolean,
nullable=False, nullable=False,
default=True, default=True,
comment="활성화 상태 (비활성화 시 사용 중지)", comment="연동 활성화 상태 (비활성화 시 사용 중지)",
) )
is_deleted: Mapped[bool] = mapped_column( is_deleted: Mapped[bool] = mapped_column(
@ -565,10 +554,9 @@ 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(
@ -579,20 +567,12 @@ 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:

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

@ -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):
"""토큰 갱신 요청""" """토큰 갱신 요청"""

View File

@ -5,11 +5,9 @@
""" """
import logging import logging
from datetime import datetime
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.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -18,72 +16,19 @@ 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.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,
) )
from app.user.services.jwt import ( from app.user.services.jwt import (
create_access_token, create_access_token,
@ -168,7 +113,7 @@ class AuthService:
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}") logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
# 7. 마지막 로그인 시간 업데이트 # 7. 마지막 로그인 시간 업데이트
user.last_login_at = now().replace(tzinfo=None) user.last_login_at = datetime.now()
await session.commit() await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}" redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
@ -188,129 +133,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():
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_uuid = payload.get("sub")
user = await self._get_user_by_uuid(user_uuid, session) user = await self._get_user_by_uuid(user_uuid, 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
db_token.revoked_at = now().replace(tzinfo=None)
logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...")
# 7. 새 토큰 발급
new_access_token = create_access_token(user.user_uuid) 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만 수행) return AccessTokenResponse(
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 +205,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 +219,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,
@ -481,11 +349,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(
@ -565,7 +428,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(),
) )
) )
await session.commit() await session.commit()
@ -590,7 +453,7 @@ class AuthService:
) )
.values( .values(
is_revoked=True, is_revoked=True,
revoked_at=now().replace(tzinfo=None), revoked_at=datetime.now(),
) )
) )
await session.commit() await session.commit()

View File

@ -5,18 +5,13 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니
""" """
import hashlib import hashlib
import logging
from datetime import datetime, timedelta 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_uuid: str) -> str: def create_access_token(user_uuid: str) -> str:
""" """
@ -28,7 +23,7 @@ def create_access_token(user_uuid: str) -> str:
Returns: Returns:
JWT 액세스 토큰 문자열 JWT 액세스 토큰 문자열
""" """
expire = now() + timedelta( expire = datetime.now() + timedelta(
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
) )
to_encode = { to_encode = {
@ -36,16 +31,11 @@ def create_access_token(user_uuid: str) -> str:
"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_uuid: str) -> str:
@ -58,7 +48,7 @@ def create_refresh_token(user_uuid: str) -> str:
Returns: Returns:
JWT 리프레시 토큰 문자열 JWT 리프레시 토큰 문자열
""" """
expire = now() + timedelta( expire = datetime.now() + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )
to_encode = { to_encode = {
@ -66,16 +56,11 @@ def create_refresh_token(user_uuid: str) -> str:
"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
@ -138,7 +106,7 @@ def get_refresh_token_expires_at() -> datetime:
Returns: Returns:
리프레시 토큰 만료 datetime (로컬 시간) 리프레시 토큰 만료 datetime (로컬 시간)
""" """
return now().replace(tzinfo=None) + timedelta( return datetime.now() + 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,48 +0,0 @@
from pydantic.main import BaseModel
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import image_autotag_prompt
from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommended
import asyncio
async def autotag_image(image_url : str) -> list[str]: #tag_list
chatgpt = ChatgptService(model_type="gemini")
image_input_data = {
"img_url" : image_url,
"space_type" : list(SpaceType),
"subject" : list(Subject),
"camera" : list(Camera),
"motion_recommended" : list(MotionRecommended)
}
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
return image_result
async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
chatgpt = ChatgptService(model_type="gemini")
image_input_data_list = [{
"img_url" : image_url,
"space_type" : list(SpaceType),
"subject" : list(Subject),
"camera" : list(Camera),
"motion_recommended" : list(MotionRecommended)
}for image_url in image_url_list]
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
MAX_RETRY = 3 # 하드코딩, 어떻게 처리할지는 나중에
for _ in range(MAX_RETRY):
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
print("Failed", failed_idx)
if not failed_idx:
break
retried = await asyncio.gather(
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
return_exceptions=True
)
for i, result in zip(failed_idx, retried):
image_result_list[i] = result
print("Failed", failed_idx)
return image_result_list

View File

@ -0,0 +1,94 @@
import json
import re
from pydantic import BaseModel
from openai import AsyncOpenAI
from app.utils.logger import get_logger
from config import apikey_settings, recovery_settings
from app.utils.prompts.prompts import Prompt
# 로거 설정
logger = get_logger("chatgpt")
class ChatGPTResponseError(Exception):
"""ChatGPT API 응답 에러"""
def __init__(self, status: str, error_code: str = None, error_message: str = None):
self.status = status
self.error_code = error_code
self.error_message = error_message
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
class ChatgptService:
"""ChatGPT API 서비스 클래스
"""
def __init__(self, timeout: float = None):
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
self.client = AsyncOpenAI(
api_key=apikey_settings.CHATGPT_API_KEY,
timeout=self.timeout
)
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
content = [{"type": "input_text", "text": prompt}]
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.responses.parse(
model=model,
input=[{"role": "user", "content": content}],
text_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService] Response ID: {response.id}")
logger.debug(f"[ChatgptService] Response status: {response.status}")
logger.debug(f"[ChatgptService] Response model: {response.model}")
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
if response.status == "completed":
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
structured_output = response.output_parsed
return structured_output #.model_dump() or {}
# 에러 상태 처리
if response.status == "failed":
error_code = getattr(response.error, 'code', None) if response.error else None
error_message = getattr(response.error, 'message', None) if response.error else None
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
last_error = ChatGPTResponseError(response.status, error_code, error_message)
elif response.status == "incomplete":
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
else:
# cancelled, queued, in_progress 등 예상치 못한 상태
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
# 마지막 시도가 아니면 재시도
if attempt < self.max_retries:
logger.info(f"[ChatgptService] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
raise last_error
async def generate_structured_output(
self,
prompt : Prompt,
input_data : dict,
) -> str:
prompt_text = prompt.build_prompt(input_data)
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
# GPT API 호출
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
return response

View File

@ -31,13 +31,11 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
import copy import copy
import time import time
from enum import StrEnum
from typing import Literal from typing import Literal
import traceback
import httpx import httpx
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.prompts.schemas.image import SpaceType,Subject,Camera,MotionRecommended,NarrativePhase
from config import apikey_settings, creatomate_settings, recovery_settings from config import apikey_settings, creatomate_settings, recovery_settings
# 로거 설정 # 로거 설정
@ -124,38 +122,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,
@ -180,71 +147,6 @@ 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"
}
DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0"
DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386"
DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
HST_LIST = [DHST0001,DHST0002,DHST0003]
VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
SCENE_TRACK = 1
AUDIO_TRACK = 2
SUBTITLE_TRACK = 3
KEYWORD_TRACK = 4
def select_template(orientation:OrientationType):
if orientation == "horizontal":
return DHST0001
elif orientation == "vertical":
return DVST0001T
else:
raise
async def get_shared_client() -> httpx.AsyncClient: async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_client global _shared_client
@ -288,10 +190,23 @@ class CreatomateService:
BASE_URL = "https://api.creatomate.com" BASE_URL = "https://api.creatomate.com"
# 템플릿 설정 (config에서 가져옴)
TEMPLATE_CONFIG = {
"horizontal": {
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
},
"vertical": {
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
},
}
def __init__( def __init__(
self, self,
api_key: str | None = None, api_key: str | None = None,
orientation: OrientationType = "vertical" orientation: OrientationType = "vertical",
target_duration: float | None = None,
): ):
""" """
Args: Args:
@ -305,7 +220,14 @@ class CreatomateService:
self.orientation = orientation self.orientation = orientation
# orientation에 따른 템플릿 설정 가져오기 # orientation에 따른 템플릿 설정 가져오기
self.template_id = select_template(orientation) config = self.TEMPLATE_CONFIG.get(
orientation, self.TEMPLATE_CONFIG["vertical"]
)
self.template_id = config["template_id"]
self.target_duration = (
target_duration if target_duration is not None else config["duration"]
)
self.headers = { self.headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
@ -402,6 +324,14 @@ class CreatomateService:
return copy.deepcopy(data) return copy.deepcopy(data)
# 하위 호환성을 위한 별칭 (deprecated)
async def get_one_template_data_async(self, template_id: str) -> dict:
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
Deprecated: get_one_template_data() 사용하세요.
"""
return await self.get_one_template_data(template_id)
def parse_template_component_name(self, template_source: list) -> dict: def parse_template_component_name(self, template_source: list) -> dict:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" """템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
@ -429,117 +359,27 @@ class CreatomateService:
return result return result
async def parse_template_name_tag(resource_name : str) -> list: async def template_connect_resource_blackbox(
tag_list = []
tag_list = resource_name.split("_")
return tag_list
def counting_component(
self, self,
template : dict, template_id: str,
target_template_type : str
) -> list:
source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements)
count = 0
for _, (_, template_type) in enumerate(template_component_data.items()):
if template_type == target_template_type:
count += 1
return count
def template_matching_taged_image(
self,
template : dict,
taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}]
music_url: str,
address : str,
duplicate : bool = False
) -> list:
source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements)
modifications = {}
for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
match template_type:
case "image":
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
maximum_idx = image_score_list.index(max(image_score_list))
if duplicate:
selected = taged_image_list[maximum_idx]
else:
selected = taged_image_list.pop(maximum_idx)
image_name = selected["image_url"]
modifications[template_component_name] =image_name
pass
case "text":
if "address_input" in template_component_name:
modifications[template_component_name] = address
modifications["audio-music"] = music_url
return modifications
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
image_score_list = [0] * len(image_tag_list)
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
if slot_tag_cate == "narrative_preference":
slot_tag_narrative = slot_tag_item
continue
match slot_tag_cate:
case "space_type":
weight = 2
case "subject" :
weight = 2
case "camera":
weight = 1
case "motion_recommended" :
weight = 0.5
case _:
raise
for idx, image_tag in enumerate(image_tag_list):
if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
image_score_list[idx] += weight
for idx, image_tag in enumerate(image_tag_list):
image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
image_score_list[idx] = image_score_list[idx] * image_narrative_score
return image_score_list
def parse_slot_name_to_tag(self, slot_name : str) -> dict[str, StrEnum]:
tag_list = slot_name.split("-")
space_type = SpaceType(tag_list[0])
subject = Subject(tag_list[1])
camera = Camera(tag_list[2])
motion = MotionRecommended(tag_list[3])
narrative = NarrativePhase(tag_list[4])
tag_dict = {
"space_type" : space_type,
"subject" : subject,
"camera" : camera,
"motion_recommended" : motion,
"narrative_preference" : narrative,
}
return tag_dict
def elements_connect_resource_blackbox(
self,
elements: list,
image_url_list: list[str], image_url_list: list[str],
lyric: str,
music_url: str, music_url: str,
address: str = None
) -> dict: ) -> dict:
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다.""" """템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
template_component_data = self.parse_template_component_name(elements)
Note:
- 이미지는 순차적으로 집어넣기
- 가사는 개행마다 텍스트 삽입
- Template에 audio-music 항목이 있어야
"""
template_data = await self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name(
template_data["source"]["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(
@ -551,8 +391,40 @@ 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
return modifications
def elements_connect_resource_blackbox(
self,
elements: list,
image_url_list: list[str],
lyric: str,
music_url: str,
) -> dict:
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
template_component_data = self.parse_template_component_name(elements)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
@ -571,8 +443,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)
@ -729,6 +600,14 @@ class CreatomateService:
original_response={"last_error": str(last_error)}, original_response={"last_error": str(last_error)},
) )
# 하위 호환성을 위한 별칭 (deprecated)
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
Deprecated: make_creatomate_custom_call() 사용하세요.
"""
return await self.make_creatomate_custom_call(source)
async def get_render_status(self, render_id: str) -> dict: async def get_render_status(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다. """렌더링 작업의 상태를 조회합니다.
@ -752,58 +631,47 @@ class CreatomateService:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
# 하위 호환성을 위한 별칭 (deprecated)
async def get_render_status_async(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다.
Deprecated: get_render_status() 사용하세요.
"""
return await self.get_render_status(render_id)
def calc_scene_duration(self, template: dict) -> float: def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다.""" """템플릿의 전체 장면 duration을 계산합니다."""
total_template_duration = 0.0 total_template_duration = 0.0
track_maximum_duration = {
SCENE_TRACK : 0,
SUBTITLE_TRACK : 0,
KEYWORD_TRACK : 0
}
for elem in template["source"]["elements"]: for elem in template["source"]["elements"]:
try: try:
if elem["track"] not in track_maximum_duration: if elem["type"] == "audio":
continue continue
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음 total_template_duration += elem["duration"]
track_maximum_duration[elem["track"]] += elem["duration"]
if "animations" not in elem: if "animations" not in elem:
continue continue
for animation in elem["animations"]: for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
if "transition" in animation and animation["transition"]: if animation["transition"]:
track_maximum_duration[elem["track"]] -= animation["duration"] total_template_duration -= animation["duration"]
else:
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
except Exception as e: except Exception as e:
logger.debug(traceback.format_exc())
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}") logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
total_template_duration = max(track_maximum_duration.values())
return total_template_duration return total_template_duration
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)
for elem in new_template["source"]["elements"]: for elem in new_template["source"]["elements"]:
try: try:
# if elem["type"] == "audio": if elem["type"] == "audio":
# continue
if elem["track"] == AUDIO_TRACK : # audio track은 패스
continue continue
if "time" in elem:
elem["time"] = elem["time"] * extend_rate
if "duration" in elem:
elem["duration"] = elem["duration"] * extend_rate elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem: if "animations" not in elem:
continue continue
for animation in elem["animations"]: for animation in elem["animations"]:
@ -816,7 +684,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}"
@ -824,45 +692,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 return text_scene
def auto_lyric(self, auto_text_template : dict):
text_scene = copy.deepcopy(auto_text_template)
return text_scene
def get_text_template(self): 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
def extract_text_format_from_template(self, template:dict):
keyword_list = []
subtitle_list = []
for elem in template["source"]["elements"]:
try: #최상위 내 텍스트만 검사
if elem["type"] == "text":
if elem["track"] == SUBTITLE_TRACK:
subtitle_list.append(elem["name"])
elif elem["track"] == KEYWORD_TRACK:
keyword_list.append(elem["name"])
except Exception as e:
logger.error(
f"[extend_template_duration] Error processing element: {elem}, {e}"
)
try:
assert(len(keyword_list)==len(subtitle_list))
except Exception as E:
logger.error("this template does not have same amount of keyword and subtitle.")
pitching_list = keyword_list + subtitle_list
return pitching_list

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,11 +1,6 @@
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
# 로거 설정
logger = get_logger("pwscraper")
class NvMapPwScraper(): class NvMapPwScraper():
# cls vars # cls vars
@ -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,54 +90,22 @@ 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>", "") title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "") address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}") encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}" url = f"https://map.naver.com/p/search/{encoded_query}"
wait_first_start = time.perf_counter() await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
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: if "/place/" in self.page.url:
return 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&") url = self.page.url.replace("?","?isCorrectAnswer=true&")
try: await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
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: if "/place/" in self.page.url:
return self.page.url return self.page.url
count += 1
logger.error("[ERROR] Not found url for {selected}")
return None # 404
# if (count == self._max_retry / 2): # if (count == self._max_retry / 2):
# raise Exception("Failed to identify place id. loading timeout") # raise Exception("Failed to identify place id. loading timeout")

View File

@ -16,10 +16,6 @@ class GraphQLException(Exception):
"""GraphQL 요청 실패 시 발생하는 예외""" """GraphQL 요청 실패 시 발생하는 예외"""
pass pass
class URLNotFoundException(Exception):
"""Place ID 발견 불가능 시 발생하는 예외"""
pass
class CrawlingTimeoutException(Exception): class CrawlingTimeoutException(Exception):
"""크롤링 타임아웃 시 발생하는 예외""" """크롤링 타임아웃 시 발생하는 예외"""
@ -34,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}) {
@ -90,20 +86,19 @@ query getAccommodation($id: String!, $deviceType: String) {
async with session.get(self.url) as response: async with session.get(self.url) as response:
self.url = str(response.url) self.url = str(response.url)
else: else:
raise URLNotFoundException("This URL does not contain a place ID") raise GraphQLException("This URL does not contain a place ID")
match = re.search(place_pattern, self.url) match = re.search(place_pattern, self.url)
if not match: if not match:
raise URLNotFoundException("Failed to parse place ID from URL") raise GraphQLException("Failed to parse place ID from URL")
return match[1] return match[1]
async def scrap(self): async def scrap(self):
try:
place_id = await self.parse_url() place_id = await self.parse_url()
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"]
@ -113,6 +108,11 @@ query getAccommodation($id: String!, $deviceType: String) {
self.facility_info = fac_data self.facility_info = fac_data
self.scrap_type = "GraphQL" self.scrap_type = "GraphQL"
except GraphQLException:
logger.debug("GraphQL failed, fallback to Playwright")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
return return
async def _call_get_accommodation(self, place_id: str) -> dict: async def _call_get_accommodation(self, place_id: str) -> dict:

View File

@ -1,191 +0,0 @@
import json
import re
from pydantic import BaseModel
from typing import List, Optional
from openai import AsyncOpenAI
from app.utils.logger import get_logger
from config import apikey_settings, recovery_settings
from app.utils.prompts.prompts import Prompt
# 로거 설정
logger = get_logger("chatgpt")
class ChatGPTResponseError(Exception):
"""ChatGPT API 응답 에러"""
def __init__(self, status: str, error_code: str = None, error_message: str = None):
self.status = status
self.error_code = error_code
self.error_message = error_message
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
class ChatgptService:
"""ChatGPT API 서비스 클래스
"""
model_type : str
def __init__(self, model_type:str = "gpt", timeout: float = None):
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
self.model_type = model_type
match model_type:
case "gpt":
self.client = AsyncOpenAI(
api_key=apikey_settings.CHATGPT_API_KEY,
timeout=self.timeout
)
case "gemini":
self.client = AsyncOpenAI(
api_key=apikey_settings.GEMINI_API_KEY,
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
timeout=self.timeout
)
case _:
raise NotImplementedError(f"Unknown Provider : {model_type}")
async def _call_pydantic_output(
self,
prompt : str,
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str,
img_url : str,
image_detail_high : bool) -> BaseModel:
content = []
if img_url:
content.append({
"type" : "input_image",
"image_url" : img_url,
"detail": "high" if image_detail_high else "low"
})
content.append({
"type": "input_text",
"text": prompt}
)
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.responses.parse(
model=model,
input=[{"role": "user", "content": content}],
text_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response status: {response.status}")
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
if response.status == "completed":
logger.debug(f"[ChatgptService({self.model_type})] 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({self.model_type})] 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({self.model_type})] 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({self.model_type})] 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({self.model_type})] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
raise last_error
async def _call_pydantic_output_chat_completion( # alter version
self,
prompt : str,
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str,
img_url : str,
image_detail_high : bool) -> BaseModel:
content = []
if img_url:
content.append({
"type": "image_url",
"image_url": {
"url": img_url,
"detail": "high" if image_detail_high else "low"
}
})
content.append({
"type": "text",
"text": prompt
})
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.beta.chat.completions.parse(
model=model,
messages=[{"role": "user", "content": content}],
response_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
choice = response.choices[0]
finish_reason = choice.finish_reason
if finish_reason == "stop":
output_text = choice.message.content or ""
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
return choice.message.parsed
elif finish_reason == "length":
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete - token limit reached (attempt {attempt + 1}/{self.max_retries + 1})")
last_error = ChatGPTResponseError("incomplete", finish_reason, "Response incomplete: max tokens reached")
elif finish_reason == "content_filter":
logger.warning(f"[ChatgptService({self.model_type})] Response blocked by content filter (attempt {attempt + 1}/{self.max_retries + 1})")
last_error = ChatGPTResponseError("failed", finish_reason, "Response blocked by content filter")
else:
logger.warning(f"[ChatgptService({self.model_type})] Unexpected finish_reason (attempt {attempt + 1}/{self.max_retries + 1}): {finish_reason}")
last_error = ChatGPTResponseError("failed", finish_reason, f"Unexpected finish_reason: {finish_reason}")
# 마지막 시도가 아니면 재시도
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
raise last_error
async def generate_structured_output(
self,
prompt : Prompt,
input_data : dict,
img_url : Optional[str] = None,
img_detail_high : bool = False,
silent : bool = False
) -> BaseModel:
prompt_text = prompt.build_prompt(input_data, silent)
logger.debug(f"[ChatgptService({self.model_type})] Generated Prompt (length: {len(prompt_text)})")
if not silent:
logger.info(f"[ChatgptService({self.model_type})] Starting GPT request with structured output with model: {prompt.prompt_model}")
# GPT API 호출
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
return parsed

View File

@ -1,89 +1,57 @@
import gspread import os, json
from pydantic import BaseModel from pydantic import BaseModel
from google.oauth2.service_account import Credentials
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 * from app.utils.prompts.schemas import *
from functools import lru_cache
logger = get_logger("prompt") logger = get_logger("prompt")
_SCOPES = [
"https://www.googleapis.com/auth/spreadsheets.readonly",
"https://www.googleapis.com/auth/drive.readonly"
]
class Prompt(): class Prompt():
sheet_name: str prompt_template_path : str #프롬프트 경로
prompt_template: str prompt_template : str # fstring 포맷
prompt_model : str prompt_model : str
prompt_input_class = BaseModel prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
prompt_output_class = BaseModel prompt_output_class = BaseModel
def __init__(self, sheet_name, prompt_input_class, prompt_output_class): def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
self.sheet_name = sheet_name self.prompt_template_path = prompt_template_path
self.prompt_input_class = prompt_input_class self.prompt_input_class = prompt_input_class
self.prompt_output_class = prompt_output_class self.prompt_output_class = prompt_output_class
self.prompt_template, self.prompt_model = self._read_from_sheets() self.prompt_template = self.read_prompt()
self.prompt_model = prompt_model
def _read_from_sheets(self) -> tuple[str, str]:
creds = Credentials.from_service_account_file(
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
)
gc = gspread.authorize(creds)
ws = gc.open_by_key(prompt_settings.PROMPT_SPREADSHEET).worksheet(self.sheet_name)
model = ws.cell(2, 2).value
input_text = ws.cell(3, 2).value
return input_text, model
def _reload_prompt(self): def _reload_prompt(self):
self.prompt_template, self.prompt_model = self._read_from_sheets() self.prompt_template = self.read_prompt()
def build_prompt(self, input_data:dict, silent:bool = False) -> str: def read_prompt(self) -> tuple[str, dict]:
with open(self.prompt_template_path, "r") as fp:
prompt_template = fp.read()
return prompt_template
def build_prompt(self, input_data:dict) -> str:
verified_input = self.prompt_input_class(**input_data) verified_input = self.prompt_input_class(**input_data)
build_template = self.prompt_template build_template = self.prompt_template
build_template = build_template.format(**verified_input.model_dump()) build_template = build_template.format(**verified_input.model_dump())
if not silent:
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}")
return build_template return build_template
marketing_prompt = Prompt( marketing_prompt = Prompt(
sheet_name="marketing", prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
prompt_input_class = MarketingPromptInput, prompt_input_class = MarketingPromptInput,
prompt_output_class = MarketingPromptOutput, prompt_output_class = MarketingPromptOutput,
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
) )
lyric_prompt = Prompt( lyric_prompt = Prompt(
sheet_name="lyric", prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
prompt_input_class = LyricPromptInput, prompt_input_class = LyricPromptInput,
prompt_output_class = LyricPromptOutput, prompt_output_class = LyricPromptOutput,
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
) )
yt_upload_prompt = Prompt(
sheet_name="yt_upload",
prompt_input_class=YTUploadPromptInput,
prompt_output_class=YTUploadPromptOutput,
)
image_autotag_prompt = Prompt(
sheet_name="image_tag",
prompt_input_class=ImageTagPromptInput,
prompt_output_class=ImageTagPromptOutput,
)
@lru_cache()
def create_dynamic_subtitle_prompt(length: int) -> Prompt:
return Prompt(
sheet_name="subtitle",
prompt_input_class=SubtitlePromptInput,
prompt_output_class=SubtitlePromptOutput[length],
)
def reload_all_prompt(): def reload_all_prompt():
marketing_prompt._reload_prompt() marketing_prompt._reload_prompt()
lyric_prompt._reload_prompt() lyric_prompt._reload_prompt()
yt_upload_prompt._reload_prompt()
image_autotag_prompt._reload_prompt()

View File

@ -1,5 +1,2 @@
from .lyric import LyricPromptInput, LyricPromptOutput from .lyric import LyricPromptInput, LyricPromptOutput
from .marketing import MarketingPromptInput, MarketingPromptOutput from .marketing import MarketingPromptInput, MarketingPromptOutput
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
from .image import *
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput

View File

@ -1,110 +0,0 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import StrEnum, auto
class SpaceType(StrEnum):
exterior_front = auto()
exterior_night = auto()
exterior_aerial = auto()
exterior_sign = auto()
garden = auto()
entrance = auto()
lobby = auto()
reception = auto()
hallway = auto()
bedroom = auto()
livingroom = auto()
kitchen = auto()
dining = auto()
room = auto()
bathroom = auto()
amenity = auto()
view_window = auto()
view_ocean = auto()
view_city = auto()
view_mountain = auto()
balcony = auto()
cafe = auto()
lounge = auto()
rooftop = auto()
pool = auto()
breakfast_hall = auto()
spa = auto()
fitness = auto()
bbq = auto()
terrace = auto()
glamping = auto()
neighborhood = auto()
landmark = auto()
detail_welcome = auto()
detail_beverage = auto()
detail_lighting = auto()
detail_decor = auto()
detail_tableware = auto()
class Subject(StrEnum):
empty_space = auto()
exterior_building = auto()
architecture_detail = auto()
decoration = auto()
furniture = auto()
food_dish = auto()
nature = auto()
signage = auto()
amenity_item = auto()
person = auto()
class Camera(StrEnum):
wide_angle = auto()
tight_crop = auto()
panoramic = auto()
symmetrical = auto()
leading_line = auto()
golden_hour = auto()
night_shot = auto()
high_contrast = auto()
low_light = auto()
drone_shot = auto()
has_face = auto()
class MotionRecommended(StrEnum):
static = auto()
slow_pan = auto()
slow_zoom_in = auto()
slow_zoom_out = auto()
walkthrough = auto()
dolly = auto()
class NarrativePhase(StrEnum):
intro = auto()
welcome = auto()
core = auto()
highlight = auto()
support = auto()
accent = auto()
class NarrativePreference(BaseModel):
intro: float = Field(..., description="첫인상 — 여기가 어디인가 | 장소의 정체성과 위치를 전달하는 이미지. 영상 첫 1~2초에 어떤 곳인지 즉시 인지시키는 역할. 건물 외관, 간판, 정원 등 **장소 자체를 보여주는** 컷")
welcome: float = Field(..., description="진입/환영 — 어떻게 들어가나 | 도착 후 내부로 들어가는 경험을 전달하는 이미지. 공간의 첫 분위기와 동선을 보여줘 들어가고 싶다는 기대감을 만드는 역할. **문을 열고 들어갔을 때 보이는** 컷.")
core: float = Field(..., description="핵심 가치 — 무엇을 경험하나 | **고객이 이 장소를 찾는 본질적 이유.** 이 이미지가 없으면 영상 자체가 성립하지 않음. 질문: 이 비즈니스에서 돈을 지불하는 대상이 뭔가? → 그 답이 core.")
highlight: float = Field(..., description="차별화 — 뭐가 특별한가 | **같은 카테고리의 경쟁사 대비 이곳을 선택하게 만드는 이유.** core가 왜 왔는가라면, highlight는 왜 **여기**인가에 대한 답.")
support: float = Field(..., description="보조/부대 — 그 외에 뭐가 있나 | 핵심은 아니지만 전체 경험을 풍성하게 하는 부가 요소. 없어도 영상은 성립하지만, 있으면 설득력이 올라감. **이것도 있어요** 라고 말하는 컷.")
accent: float = Field(..., description="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.")
# Input 정의
class ImageTagPromptInput(BaseModel):
img_url : str = Field(..., description="이미지 URL")
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트")
# Output 정의
class ImageTagPromptOutput(BaseModel):
#ad_avaliable : bool = Field(..., description="광고 영상 사용 가능 이미지 여부")
space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트")
subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트")
camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트")
motion_recommended: list[MotionRecommended] = Field(..., description="가능한 카메라 모션 리스트")
narrative_preference: NarrativePreference = Field(..., description="이미지의 내러티브 상 점수")

View File

@ -7,11 +7,13 @@ class MarketingPromptInput(BaseModel):
region : str = Field(..., description = "마케팅 대상 지역") region : str = Field(..., description = "마케팅 대상 지역")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
# Output 정의 # Output 정의
class BrandIdentity(BaseModel): class BrandIdentity(BaseModel):
location_feature_analysis: str = Field(..., description="입지 특성 분석 (80자 이상 150자 이하)", min_length = 80, max_length = 150) # min/max constraint는 현재 openai json schema 등에서 작동하지 않는다는 보고가 있음. location_feature_analysis: str = Field(..., description="입지 특성 분석 (80자 이상 150자 이하)", min_length = 80, max_length = 150) # min/max constraint는 현재 openai json schema 등에서 작동하지 않는다는 보고가 있음.
concept_scalability: str = Field(..., description="컨셉 확장성 (80자 이상 150자 이하)", min_length = 80, max_length = 150) concept_scalability: str = Field(..., description="컨셉 확장성 (80자 이상 150자 이하)", min_length = 80, max_length = 150)
class MarketPositioning(BaseModel): class MarketPositioning(BaseModel):
category_definition: str = Field(..., description="마케팅 카테고리") category_definition: str = Field(..., description="마케팅 카테고리")
core_value: str = Field(..., description="마케팅 포지션 핵심 가치") core_value: str = Field(..., description="마케팅 포지션 핵심 가치")
@ -20,17 +22,18 @@ class AgeRange(BaseModel):
min_age : int = Field(..., ge=0, le=100) min_age : int = Field(..., ge=0, le=100)
max_age : int = Field(..., ge=0, le=100) max_age : int = Field(..., ge=0, le=100)
class TargetPersona(BaseModel): class TargetPersona(BaseModel):
persona: str = Field(..., description="타겟 페르소나 이름/설명") persona: str = Field(..., description="타겟 페르소나 이름/설명")
age: AgeRange = Field(..., description="타겟 페르소나 나이대") age: AgeRange = Field(..., description="타겟 페르소나 나이대")
favor_target: List[str] = Field(..., description="페르소나의 선호 요소") favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
decision_trigger: str = Field(..., description="구매 결정 트리거") decision_trigger: str = Field(..., description="구매 결정 트리거")
class SellingPoint(BaseModel): class SellingPoint(BaseModel):
english_category: str = Field(..., description="셀링포인트 카테고리(영문)") category: str = Field(..., description="셀링포인트 카테고리")
korean_category: str = Field(..., description="셀링포인트 카테고리(한글)")
description: str = Field(..., description="상세 설명") description: str = Field(..., description="상세 설명")
score: int = Field(..., ge=0, le=100, description="점수 (100점 만점)") score: int = Field(..., ge=70, le=99, description="점수 (100점 만점)")
class MarketingPromptOutput(BaseModel): class MarketingPromptOutput(BaseModel):
brand_identity: BrandIdentity = Field(..., description="브랜드 아이덴티티") brand_identity: BrandIdentity = Field(..., description="브랜드 아이덴티티")

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