Compare commits
No commits in common. "main" and "feature-youtube-upload" have entirely different histories.
main
...
feature-yo
|
|
@ -50,6 +50,3 @@ logs/
|
|||
*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
zzz/
|
||||
credentials/service_account.json
|
||||
|
|
@ -1 +1 @@
|
|||
3.13.11
|
||||
3.14
|
||||
|
|
|
|||
|
|
@ -161,9 +161,6 @@ uv sync
|
|||
|
||||
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
|
||||
uv sync --active
|
||||
|
||||
playwright install
|
||||
playwright install-deps
|
||||
```
|
||||
|
||||
### 서버 실행
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ from app.database.session import engine
|
|||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||
from app.song.api.song_admin import SongAdmin
|
||||
from app.sns.api.sns_admin import SNSUploadTaskAdmin
|
||||
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
||||
from app.video.api.video_admin import VideoAdmin
|
||||
from config import prj_settings
|
||||
|
||||
|
|
@ -37,12 +35,4 @@ def init_admin(
|
|||
# 영상 관리
|
||||
admin.add_view(VideoAdmin)
|
||||
|
||||
# 사용자 관리
|
||||
admin.add_view(UserAdmin)
|
||||
admin.add_view(RefreshTokenAdmin)
|
||||
admin.add_view(SocialAccountAdmin)
|
||||
|
||||
# SNS 관리
|
||||
admin.add_view(SNSUploadTaskAdmin)
|
||||
|
||||
return admin
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10
|
|||
## 참고
|
||||
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
|
||||
- status가 'completed'인 영상만 반환됩니다.
|
||||
- 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다.
|
||||
- 재생성된 영상 포함 모든 영상이 반환됩니다.
|
||||
- created_at 기준 내림차순 정렬됩니다.
|
||||
""",
|
||||
response_model=PaginatedResponse[VideoListItem],
|
||||
|
|
@ -70,50 +70,112 @@ async def get_videos(
|
|||
) -> PaginatedResponse[VideoListItem]:
|
||||
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
|
||||
logger.info(
|
||||
f"[get_videos] START - user: {current_user.user_uuid}, "
|
||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
)
|
||||
logger.debug(f"[get_videos] current_user.user_uuid: {current_user.user_uuid}")
|
||||
|
||||
try:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
# 서브쿼리: task_id별 최신 Video ID 추출
|
||||
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치
|
||||
latest_video_ids = (
|
||||
select(func.max(Video.id).label("latest_id"))
|
||||
# DEBUG: 각 조건별 데이터 수 확인
|
||||
# 1) 전체 Video 수
|
||||
all_videos_result = await session.execute(select(func.count(Video.id)))
|
||||
all_videos_count = all_videos_result.scalar() or 0
|
||||
logger.debug(f"[get_videos] DEBUG - 전체 Video 수: {all_videos_count}")
|
||||
|
||||
# 2) completed 상태 Video 수
|
||||
completed_videos_result = await session.execute(
|
||||
select(func.count(Video.id)).where(Video.status == "completed")
|
||||
)
|
||||
completed_videos_count = completed_videos_result.scalar() or 0
|
||||
logger.debug(f"[get_videos] DEBUG - completed 상태 Video 수: {completed_videos_count}")
|
||||
|
||||
# 3) is_deleted=False인 Video 수
|
||||
not_deleted_videos_result = await session.execute(
|
||||
select(func.count(Video.id)).where(Video.is_deleted == False)
|
||||
)
|
||||
not_deleted_videos_count = not_deleted_videos_result.scalar() or 0
|
||||
logger.debug(f"[get_videos] DEBUG - is_deleted=False Video 수: {not_deleted_videos_count}")
|
||||
|
||||
# 4) 전체 Project 수 및 user_uuid 값 확인
|
||||
all_projects_result = await session.execute(
|
||||
select(Project.id, Project.user_uuid, Project.is_deleted)
|
||||
)
|
||||
all_projects = all_projects_result.all()
|
||||
logger.debug(f"[get_videos] DEBUG - 전체 Project 수: {len(all_projects)}")
|
||||
for p in all_projects:
|
||||
logger.debug(
|
||||
f"[get_videos] DEBUG - Project: id={p.id}, user_uuid={p.user_uuid}, "
|
||||
f"user_uuid_type={type(p.user_uuid)}, is_deleted={p.is_deleted}"
|
||||
)
|
||||
|
||||
# 4-1) 현재 사용자 UUID 타입 확인
|
||||
logger.debug(
|
||||
f"[get_videos] DEBUG - current_user.user_uuid={current_user.user_uuid}, "
|
||||
f"type={type(current_user.user_uuid)}"
|
||||
)
|
||||
|
||||
# 4-2) 현재 사용자 소유 Project 수
|
||||
user_projects_result = await session.execute(
|
||||
select(func.count(Project.id)).where(
|
||||
Project.user_uuid == current_user.user_uuid,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
user_projects_count = user_projects_result.scalar() or 0
|
||||
logger.debug(f"[get_videos] DEBUG - 현재 사용자 소유 Project 수: {user_projects_count}")
|
||||
|
||||
# 5) 현재 사용자 소유 + completed + 미삭제 Video 수
|
||||
user_completed_videos_result = await session.execute(
|
||||
select(func.count(Video.id))
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(
|
||||
Project.user_uuid == current_user.user_uuid,
|
||||
Video.status == "completed",
|
||||
Video.is_deleted == False, # noqa: E712
|
||||
Project.is_deleted == False, # noqa: E712
|
||||
Video.is_deleted == False,
|
||||
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별 최신 영상만)
|
||||
count_query = select(func.count(Video.id)).where(
|
||||
Video.id.in_(select(latest_video_ids.c.latest_id))
|
||||
# 기본 조건: 현재 사용자 소유, completed 상태, 미삭제
|
||||
base_conditions = [
|
||||
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 = total_result.scalar() or 0
|
||||
logger.debug(f"[get_videos] DEBUG - 전체 영상 개수 (total): {total}")
|
||||
|
||||
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
|
||||
data_query = (
|
||||
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상)
|
||||
query = (
|
||||
select(Video, Project)
|
||||
.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())
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
)
|
||||
result = await session.execute(data_query)
|
||||
result = await session.execute(query)
|
||||
rows = result.all()
|
||||
logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}")
|
||||
|
||||
# VideoListItem으로 변환
|
||||
items = [
|
||||
VideoListItem(
|
||||
# VideoListItem으로 변환 (JOIN 결과에서 바로 추출)
|
||||
items = []
|
||||
for video, project in rows:
|
||||
item = VideoListItem(
|
||||
video_id=video.id,
|
||||
store_name=project.store_name,
|
||||
region=project.region,
|
||||
|
|
@ -121,8 +183,7 @@ async def get_videos(
|
|||
result_movie_url=video.result_movie_url,
|
||||
created_at=video.created_at,
|
||||
)
|
||||
for video, project in rows
|
||||
]
|
||||
items.append(item)
|
||||
|
||||
response = PaginatedResponse.create(
|
||||
items=items,
|
||||
|
|
|
|||
|
|
@ -24,11 +24,6 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
await create_db_tables()
|
||||
logger.info("Database tables created (DEBUG mode)")
|
||||
|
||||
# dashboard 테이블 초기화 및 기존 데이터 마이그레이션 (모든 환경)
|
||||
from app.dashboard.migration import init_dashboard_table
|
||||
await init_dashboard_table()
|
||||
|
||||
await NvMapPwScraper.initiate_scraper()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Database initialization timed out")
|
||||
|
|
|
|||
|
|
@ -291,33 +291,9 @@ def add_exception_handlers(app: FastAPI):
|
|||
# SocialException 핸들러 추가
|
||||
from app.social.exceptions import SocialException
|
||||
|
||||
from app.social.exceptions import TokenExpiredError
|
||||
|
||||
@app.exception_handler(SocialException)
|
||||
def social_exception_handler(request: Request, exc: SocialException) -> Response:
|
||||
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
|
||||
content = {
|
||||
"detail": exc.message,
|
||||
"code": exc.code,
|
||||
}
|
||||
# TokenExpiredError인 경우 재연동 정보 추가
|
||||
if isinstance(exc, TokenExpiredError):
|
||||
content["platform"] = exc.platform
|
||||
content["reconnect_url"] = f"/social/oauth/{exc.platform}/connect"
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=content,
|
||||
)
|
||||
|
||||
# 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(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
|
|
@ -328,10 +304,11 @@ def add_exception_handlers(app: FastAPI):
|
|||
|
||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
def internal_server_error_handler(request, exception):
|
||||
# 에러 메시지 로깅 (한글 포함 가능)
|
||||
logger.error(f"Internal Server Error: {exception}")
|
||||
return JSONResponse(
|
||||
content={"detail": "Something went wrong..."},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
headers={
|
||||
"X-Error": f"{exception}",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"""
|
||||
Dashboard Module
|
||||
|
||||
YouTube Analytics API를 활용한 대시보드 기능을 제공합니다.
|
||||
"""
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Dashboard API Module
|
||||
"""
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Dashboard Routers
|
||||
"""
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
"""
|
||||
Dashboard V1 Routers
|
||||
"""
|
||||
|
||||
from app.dashboard.api.routers.v1.dashboard import router
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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 테이블 이미 존재 - 스킵")
|
||||
|
|
@ -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")>"
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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, "기타")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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}"
|
||||
)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
|
|
@ -4,6 +4,11 @@ from redis.asyncio import Redis
|
|||
from app.config import db_settings
|
||||
|
||||
|
||||
_token_blacklist = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=0,
|
||||
)
|
||||
_shipment_verification_codes = Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
|
|
@ -11,10 +16,15 @@ _shipment_verification_codes = Redis(
|
|||
decode_responses=True,
|
||||
)
|
||||
|
||||
async def add_jti_to_blacklist(jti: str):
|
||||
await _token_blacklist.set(jti, "blacklisted")
|
||||
|
||||
|
||||
async def is_jti_blacklisted(jti: str) -> bool:
|
||||
return await _token_blacklist.exists(jti)
|
||||
|
||||
async def add_shipment_verification_code(id: UUID, code: int):
|
||||
await _shipment_verification_codes.set(str(id), code)
|
||||
|
||||
|
||||
async def get_shipment_verification_code(id: UUID) -> str:
|
||||
return str(await _shipment_verification_codes.get(str(id)))
|
||||
|
|
@ -6,7 +6,6 @@ from sqlalchemy.orm import DeclarativeBase
|
|||
|
||||
from app.utils.logger import get_logger
|
||||
from config import db_settings
|
||||
import traceback
|
||||
|
||||
logger = get_logger("database")
|
||||
|
||||
|
|
@ -74,13 +73,10 @@ async def create_db_tables():
|
|||
|
||||
# 모델 import (테이블 메타데이터 등록용)
|
||||
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.song.models import Song, SongTimestamp # 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 = [
|
||||
|
|
@ -93,11 +89,6 @@ async def create_db_tables():
|
|||
Song.__table__,
|
||||
SongTimestamp.__table__,
|
||||
Video.__table__,
|
||||
SNSUploadTask.__table__,
|
||||
SocialUpload.__table__,
|
||||
MarketingIntel.__table__,
|
||||
Dashboard.__table__,
|
||||
ImageTag.__table__,
|
||||
]
|
||||
|
||||
logger.info("Creating database tables...")
|
||||
|
|
@ -130,9 +121,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
import traceback
|
||||
await session.rollback()
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(
|
||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
|
|
@ -172,7 +161,6 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
f"error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
)
|
||||
logger.debug(traceback.format_exc())
|
||||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ import aiofiles
|
|||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import func, select
|
||||
|
||||
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.models import User
|
||||
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.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.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.prompts.prompts import marketing_prompt
|
||||
from app.utils.autotag import autotag_images
|
||||
from config import MEDIA_ROOT
|
||||
|
||||
# 로거 설정
|
||||
|
|
@ -155,10 +153,8 @@ def _extract_region_from_address(road_address: str | None) -> str:
|
|||
},
|
||||
tags=["Crawling"],
|
||||
)
|
||||
async def crawling(
|
||||
request_body: CrawlingRequest,
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
return await _crawling_logic(request_body.url, session)
|
||||
async def crawling(request_body: CrawlingRequest):
|
||||
return await _crawling_logic(request_body.url)
|
||||
|
||||
@router.post(
|
||||
"/autocomplete",
|
||||
|
|
@ -191,15 +187,11 @@ async def crawling(
|
|||
},
|
||||
tags=["Crawling"],
|
||||
)
|
||||
async def autocomplete_crawling(
|
||||
request_body: AutoCompleteRequest,
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
url = await _autocomplete_logic(request_body.model_dump())
|
||||
return await _crawling_logic(url, session)
|
||||
async def autocomplete_crawling(request_body: AutoCompleteRequest):
|
||||
url = await _autocomplete_logic(request_body.dict())
|
||||
return await _crawling_logic(url)
|
||||
|
||||
async def _crawling_logic(
|
||||
url:str,
|
||||
session: AsyncSession):
|
||||
async def _crawling_logic(url:str):
|
||||
request_start = time.perf_counter()
|
||||
logger.info("[crawling] ========== START ==========")
|
||||
logger.info(f"[crawling] URL: {url[:80]}...")
|
||||
|
|
@ -220,15 +212,6 @@ async def _crawling_logic(
|
|||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
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:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(
|
||||
|
|
@ -298,15 +281,6 @@ async def _crawling_logic(
|
|||
structured_report = await chatgpt_service.generate_structured_output(
|
||||
marketing_prompt, input_marketing_data
|
||||
)
|
||||
marketing_intelligence = MarketingIntel(
|
||||
place_id = scraper.place_id,
|
||||
intel_result = structured_report.model_dump()
|
||||
)
|
||||
session.add(marketing_intelligence)
|
||||
await session.commit()
|
||||
await session.refresh(marketing_intelligence)
|
||||
m_id = marketing_intelligence.id
|
||||
logger.debug(f"[MarketingPrompt] INSERT placeid {marketing_intelligence.place_id}")
|
||||
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||
logger.info(
|
||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||
|
|
@ -386,7 +360,6 @@ async def _crawling_logic(
|
|||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||
"processed_info": processed_info,
|
||||
"marketing_analysis": marketing_analysis,
|
||||
"m_id" : m_id
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -402,7 +375,7 @@ async def _autocomplete_logic(autocomplete_item:dict):
|
|||
)
|
||||
logger.exception("[crawling] Autocomplete 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="자동완성 place id 추출 실패",
|
||||
)
|
||||
|
||||
|
|
@ -462,6 +435,255 @@ IMAGES_JSON_EXAMPLE = """[
|
|||
{"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(
|
||||
"/image/upload/blob",
|
||||
summary="이미지 업로드 (Azure Blob Storage)",
|
||||
|
|
@ -750,10 +972,6 @@ async def upload_images_blob(
|
|||
saved_count = len(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
|
||||
logger.info(
|
||||
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||
|
|
@ -769,36 +987,3 @@ async def upload_images_blob(
|
|||
images=result_images,
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@ Home 모듈 SQLAlchemy 모델 정의
|
|||
"""
|
||||
|
||||
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.dialects.mysql import INTEGER
|
||||
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
|
||||
|
|
@ -108,12 +107,6 @@ class Project(Base):
|
|||
comment="상세 지역 정보",
|
||||
)
|
||||
|
||||
marketing_intelligence: Mapped[Optional[str]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
|
|
@ -256,109 +249,3 @@ class Image(Base):
|
|||
return (
|
||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||
)
|
||||
|
||||
|
||||
class MarketingIntel(Base):
|
||||
"""
|
||||
마케팅 인텔리전스 결과물 테이블
|
||||
|
||||
마케팅 분석 결과물 저장합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
place_id : 데이터 소스별 식별자
|
||||
intel_result : 마케팅 분석 결과물 json
|
||||
created_at: 생성 일시 (자동 설정)
|
||||
"""
|
||||
|
||||
__tablename__ = "marketing"
|
||||
__table_args__ = (
|
||||
Index("idx_place_id", "place_id"),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
autoincrement=True,
|
||||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
place_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
comment="매장 소스별 고유 식별자",
|
||||
)
|
||||
|
||||
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="마케팅 인텔리전스 결과물",
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
|
|
@ -3,6 +3,112 @@ from typing import Literal, Optional
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from app.utils.prompts.schemas import MarketingPromptOutput
|
||||
|
||||
class AttributeInfo(BaseModel):
|
||||
"""음악 속성 정보"""
|
||||
|
||||
genre: str = Field(..., description="음악 장르")
|
||||
vocal: str = Field(..., description="보컬 스타일")
|
||||
tempo: str = Field(..., description="템포")
|
||||
mood: str = Field(..., description="분위기")
|
||||
|
||||
|
||||
class GenerateRequestImg(BaseModel):
|
||||
"""이미지 URL 스키마"""
|
||||
|
||||
url: str = Field(..., description="이미지 URL")
|
||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class GenerateRequestInfo(BaseModel):
|
||||
"""생성 요청 정보 스키마 (이미지 제외)"""
|
||||
|
||||
customer_name: str = Field(..., description="고객명/가게명")
|
||||
region: str = Field(..., description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateRequest(GenerateRequestInfo):
|
||||
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
||||
|
||||
이미지 없이 프로젝트 정보만 전달합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GenerateUrlsRequest(GenerateRequestInfo):
|
||||
"""URL 기반 생성 요청 스키마 (JSON body)
|
||||
|
||||
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: list[GenerateRequestImg] = Field(
|
||||
..., description="이미지 URL 목록", min_length=1
|
||||
)
|
||||
|
||||
|
||||
class GenerateUploadResponse(BaseModel):
|
||||
"""파일 업로드 기반 생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
"""생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
|
||||
class CrawlingRequest(BaseModel):
|
||||
"""크롤링 요청 스키마"""
|
||||
|
||||
|
|
@ -169,44 +275,37 @@ class CrawlingResponse(BaseModel):
|
|||
],
|
||||
"selling_points": [
|
||||
{
|
||||
"english_category": "LOCATION",
|
||||
"korean_category": "입지 환경",
|
||||
"category": "LOCATION",
|
||||
"description": "군산 감성 동선",
|
||||
"score": 88
|
||||
},
|
||||
{
|
||||
"english_category": "HEALING",
|
||||
"korean_category": "힐링 요소",
|
||||
"category": "HEALING",
|
||||
"description": "멈춤이 되는 쉼",
|
||||
"score": 92
|
||||
},
|
||||
{
|
||||
"english_category": "PRIVACY",
|
||||
"korean_category": "프라이버시",
|
||||
"category": "PRIVACY",
|
||||
"description": "방해 없는 머뭄",
|
||||
"score": 86
|
||||
},
|
||||
{
|
||||
"english_category": "NIGHT MOOD",
|
||||
"korean_category": "야간 감성",
|
||||
"category": "NIGHT MOOD",
|
||||
"description": "밤이 예쁜 조명",
|
||||
"score": 84
|
||||
},
|
||||
{
|
||||
"english_category": "PHOTO SPOT",
|
||||
"korean_category": "포토 스팟",
|
||||
"category": "PHOTO SPOT",
|
||||
"description": "자연광 포토존",
|
||||
"score": 83
|
||||
},
|
||||
{
|
||||
"english_category": "SHORT GETAWAY",
|
||||
"korean_category": "숏브레이크",
|
||||
"category": "SHORT GETAWAY",
|
||||
"description": "주말 리셋 스테이",
|
||||
"score": 89
|
||||
},
|
||||
{
|
||||
"english_category": "HOSPITALITY",
|
||||
"korean_category": "서비스",
|
||||
"category": "HOSPITALITY",
|
||||
"description": "세심한 웰컴감",
|
||||
"score": 80
|
||||
}
|
||||
|
|
@ -223,8 +322,7 @@ class CrawlingResponse(BaseModel):
|
|||
"힐링스테이",
|
||||
"스테이머뭄"
|
||||
]
|
||||
},
|
||||
"m_id" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -241,7 +339,6 @@ class CrawlingResponse(BaseModel):
|
|||
marketing_analysis: Optional[MarketingPromptOutput] = Field(
|
||||
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||
)
|
||||
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
|
|
@ -265,6 +362,29 @@ class ImageUrlItem(BaseModel):
|
|||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class ImageUploadRequest(BaseModel):
|
||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
||||
|
||||
URL 이미지 목록을 전달합니다.
|
||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: Optional[list[ImageUrlItem]] = Field(
|
||||
None, description="외부 이미지 URL 목록"
|
||||
)
|
||||
|
||||
|
||||
class ImageUploadResultItem(BaseModel):
|
||||
"""업로드된 이미지 결과 아이템"""
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.home.models import Project, MarketingIntel
|
||||
from app.home.models import Project
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
from app.lyric.models import Lyric
|
||||
|
|
@ -41,14 +41,13 @@ from app.lyric.schemas.lyric import (
|
|||
LyricListItem,
|
||||
LyricStatusResponse,
|
||||
)
|
||||
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||
|
||||
from app.utils.prompts.prompts import lyric_prompt
|
||||
import traceback as tb
|
||||
import json
|
||||
# 로거 설정
|
||||
logger = get_logger("lyric")
|
||||
|
||||
|
|
@ -253,6 +252,17 @@ async def generate_lyric(
|
|||
step1_start = time.perf_counter()
|
||||
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 = {
|
||||
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
||||
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
||||
|
|
@ -268,19 +278,16 @@ async def generate_lyric(
|
|||
Full verse flow, immersive mood
|
||||
"""
|
||||
}
|
||||
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
||||
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
||||
|
||||
|
||||
lyric_input_data = {
|
||||
"customer_name" : request_body.customer_name,
|
||||
"region" : request_body.region,
|
||||
"detail_region_info" : request_body.detail_region_info or "",
|
||||
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
||||
"marketing_intelligence_summary" : None, # task_idx 변경 후 marketing intelligence summary DB에 저장하고 사용할 필요가 있음
|
||||
"language" : request_body.language,
|
||||
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||
}
|
||||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||
|
|
@ -307,7 +314,6 @@ async def generate_lyric(
|
|||
detail_region_info=request_body.detail_region_info,
|
||||
language=request_body.language,
|
||||
user_uuid=current_user.user_uuid,
|
||||
marketing_intelligence = request_body.m_id
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
|
|
@ -340,7 +346,7 @@ async def generate_lyric(
|
|||
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
||||
step4_start = time.perf_counter()
|
||||
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||
orientation = request_body.orientation
|
||||
|
||||
background_tasks.add_task(
|
||||
generate_lyric_background,
|
||||
task_id=task_id,
|
||||
|
|
@ -349,12 +355,6 @@ async def generate_lyric(
|
|||
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
|
||||
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ Lyric API Schemas
|
|||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Literal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
|
@ -41,9 +41,7 @@ class GenerateLyricRequest(BaseModel):
|
|||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean",
|
||||
"m_id" : 2,
|
||||
"orientation" : "vertical"
|
||||
"language": "Korean"
|
||||
}
|
||||
"""
|
||||
|
||||
|
|
@ -55,8 +53,6 @@ class GenerateLyricRequest(BaseModel):
|
|||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean",
|
||||
"m_id" : 1,
|
||||
"orientation" : "vertical"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -70,12 +66,7 @@ class GenerateLyricRequest(BaseModel):
|
|||
language: str = Field(
|
||||
default="Korean",
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -7,15 +7,11 @@ Lyric Background Tasks
|
|||
import traceback
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.home.models import Image, Project, MarketingIntel
|
||||
from app.lyric.models import Lyric
|
||||
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||
from app.utils.subtitles import SubtitleContentsGenerator
|
||||
from app.utils.creatomate import CreatomateService
|
||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||
from app.utils.prompts.prompts import Prompt
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
|
|
@ -104,6 +100,13 @@ async def generate_lyric_background(
|
|||
step1_start = time.perf_counter()
|
||||
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||
|
||||
# service = ChatgptService(
|
||||
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||
# region="",
|
||||
# detail_region_info="",
|
||||
# language=language,
|
||||
# )
|
||||
|
||||
chatgpt = ChatgptService()
|
||||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
|
|
@ -155,55 +158,3 @@ async def generate_lyric_background(
|
|||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
"""
|
||||
SNS API 라우터
|
||||
|
||||
Instagram 업로드 관련 엔드포인트를 제공합니다.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import Platform, SocialAccount, User
|
||||
from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
|
||||
from app.utils.logger import get_logger
|
||||
from app.video.models import Video
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SNS 예외 클래스 정의
|
||||
# =============================================================================
|
||||
class SNSException(HTTPException):
|
||||
"""SNS 관련 기본 예외"""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str):
|
||||
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
class SocialAccountNotFoundError(SNSException):
|
||||
"""소셜 계정 없음"""
|
||||
|
||||
def __init__(self, message: str = "연동된 소셜 계정을 찾을 수 없습니다."):
|
||||
super().__init__(status.HTTP_404_NOT_FOUND, "SOCIAL_ACCOUNT_NOT_FOUND", message)
|
||||
|
||||
|
||||
class VideoNotFoundError(SNSException):
|
||||
"""비디오 없음"""
|
||||
|
||||
def __init__(self, message: str = "해당 작업 ID에 대한 비디오를 찾을 수 없습니다."):
|
||||
super().__init__(status.HTTP_404_NOT_FOUND, "VIDEO_NOT_FOUND", message)
|
||||
|
||||
|
||||
class VideoUrlNotReadyError(SNSException):
|
||||
"""비디오 URL 미준비"""
|
||||
|
||||
def __init__(self, message: str = "비디오가 아직 준비되지 않았습니다."):
|
||||
super().__init__(status.HTTP_400_BAD_REQUEST, "VIDEO_URL_NOT_READY", message)
|
||||
|
||||
|
||||
class InstagramUploadError(SNSException):
|
||||
"""Instagram 업로드 실패"""
|
||||
|
||||
def __init__(self, message: str = "Instagram 업로드에 실패했습니다."):
|
||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_UPLOAD_ERROR", message)
|
||||
|
||||
|
||||
class InstagramRateLimitError(SNSException):
|
||||
"""Instagram API Rate Limit"""
|
||||
|
||||
def __init__(self, message: str = "Instagram API 호출 제한을 초과했습니다.", retry_after: int = 60):
|
||||
super().__init__(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"INSTAGRAM_RATE_LIMIT",
|
||||
f"{message} {retry_after}초 후 다시 시도해주세요.",
|
||||
)
|
||||
|
||||
|
||||
class InstagramAuthError(SNSException):
|
||||
"""Instagram 인증 오류"""
|
||||
|
||||
def __init__(self, message: str = "Instagram 인증에 실패했습니다. 계정을 다시 연동해주세요."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "INSTAGRAM_AUTH_ERROR", message)
|
||||
|
||||
|
||||
class InstagramContainerTimeoutError(SNSException):
|
||||
"""Instagram 미디어 처리 타임아웃"""
|
||||
|
||||
def __init__(self, message: str = "Instagram 미디어 처리 시간이 초과되었습니다."):
|
||||
super().__init__(status.HTTP_504_GATEWAY_TIMEOUT, "INSTAGRAM_CONTAINER_TIMEOUT", message)
|
||||
|
||||
|
||||
class InstagramContainerError(SNSException):
|
||||
"""Instagram 미디어 컨테이너 오류"""
|
||||
|
||||
def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
|
||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sns", tags=["SNS"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instagram/upload/{task_id}",
|
||||
summary="Instagram 비디오 업로드",
|
||||
description="""
|
||||
## 개요
|
||||
task_id에 해당하는 비디오를 Instagram에 업로드합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **task_id**: 비디오 생성 작업 고유 식별자
|
||||
|
||||
## 요청 본문
|
||||
- **caption**: 게시물 캡션 (선택, 최대 2200자)
|
||||
- **share_to_feed**: 피드에 공유 여부 (기본값: true)
|
||||
|
||||
## 인증
|
||||
- Bearer 토큰 필요 (Authorization: Bearer <token>)
|
||||
- 사용자의 Instagram 계정이 연동되어 있어야 합니다.
|
||||
|
||||
## 반환 정보
|
||||
- **task_id**: 작업 고유 식별자
|
||||
- **state**: 업로드 상태 (completed, failed)
|
||||
- **message**: 상태 메시지
|
||||
- **media_id**: Instagram 미디어 ID (성공 시)
|
||||
- **permalink**: Instagram 게시물 URL (성공 시)
|
||||
- **error**: 에러 메시지 (실패 시)
|
||||
""",
|
||||
response_model=InstagramUploadResponse,
|
||||
responses={
|
||||
200: {"description": "업로드 성공"},
|
||||
400: {"description": "비디오 URL 미준비"},
|
||||
401: {"description": "인증 실패"},
|
||||
404: {"description": "비디오 또는 소셜 계정 없음"},
|
||||
429: {"description": "Instagram API Rate Limit"},
|
||||
500: {"description": "업로드 실패"},
|
||||
504: {"description": "타임아웃"},
|
||||
},
|
||||
)
|
||||
async def upload_to_instagram(
|
||||
task_id: str,
|
||||
request: InstagramUploadRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InstagramUploadResponse:
|
||||
"""Instagram에 비디오를 업로드합니다."""
|
||||
logger.info(f"[upload_to_instagram] START - task_id: {task_id}, user_uuid: {current_user.user_uuid}")
|
||||
|
||||
# Step 1: 사용자의 Instagram 소셜 계정 조회
|
||||
social_account_result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == current_user.user_uuid,
|
||||
SocialAccount.platform == Platform.INSTAGRAM,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
social_account = social_account_result.scalar_one_or_none()
|
||||
|
||||
if social_account is None:
|
||||
logger.warning(f"[upload_to_instagram] Instagram 계정 없음 - user_uuid: {current_user.user_uuid}")
|
||||
raise SocialAccountNotFoundError("연동된 Instagram 계정을 찾을 수 없습니다.")
|
||||
|
||||
logger.info(f"[upload_to_instagram] 소셜 계정 확인 - social_account_id: {social_account.id}")
|
||||
|
||||
# Step 2: task_id로 비디오 조회 (가장 최근 것)
|
||||
video_result = await session.execute(
|
||||
select(Video)
|
||||
.where(
|
||||
Video.task_id == task_id,
|
||||
Video.is_deleted == False, # noqa: E712
|
||||
)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
|
||||
if video is None:
|
||||
logger.warning(f"[upload_to_instagram] 비디오 없음 - task_id: {task_id}")
|
||||
raise VideoNotFoundError(f"task_id '{task_id}'에 해당하는 비디오를 찾을 수 없습니다.")
|
||||
|
||||
if video.result_movie_url is None:
|
||||
logger.warning(f"[upload_to_instagram] 비디오 URL 미준비 - task_id: {task_id}, status: {video.status}")
|
||||
raise VideoUrlNotReadyError("비디오가 아직 처리 중입니다. 잠시 후 다시 시도해주세요.")
|
||||
|
||||
logger.info(f"[upload_to_instagram] 비디오 확인 - video_id: {video.id}, url: {video.result_movie_url[:50]}...")
|
||||
|
||||
# Step 3: Instagram 업로드
|
||||
try:
|
||||
async with InstagramClient(access_token=social_account.access_token) as client:
|
||||
# 접속 테스트 (계정 ID 조회)
|
||||
await client.get_account_id()
|
||||
logger.info("[upload_to_instagram] Instagram 접속 확인 완료")
|
||||
|
||||
# 비디오 업로드
|
||||
media = await client.publish_video(
|
||||
video_url=video.result_movie_url,
|
||||
caption=request.caption,
|
||||
share_to_feed=request.share_to_feed,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[upload_to_instagram] SUCCESS - task_id: {task_id}, "
|
||||
f"media_id: {media.id}, permalink: {media.permalink}"
|
||||
)
|
||||
|
||||
return InstagramUploadResponse(
|
||||
task_id=task_id,
|
||||
state="completed",
|
||||
message="Instagram 업로드 완료",
|
||||
media_id=media.id,
|
||||
permalink=media.permalink,
|
||||
error=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_state, message, extra_info = parse_instagram_error(e)
|
||||
logger.error(f"[upload_to_instagram] FAILED - task_id: {task_id}, error_state: {error_state}, message: {message}")
|
||||
|
||||
match error_state:
|
||||
case ErrorState.RATE_LIMIT:
|
||||
retry_after = extra_info.get("retry_after", 60)
|
||||
raise InstagramRateLimitError(retry_after=retry_after)
|
||||
|
||||
case ErrorState.AUTH_ERROR:
|
||||
raise InstagramAuthError()
|
||||
|
||||
case ErrorState.CONTAINER_TIMEOUT:
|
||||
raise InstagramContainerTimeoutError()
|
||||
|
||||
case ErrorState.CONTAINER_ERROR:
|
||||
status = extra_info.get("status", "UNKNOWN")
|
||||
raise InstagramContainerError(f"미디어 처리 실패: {status}")
|
||||
|
||||
case _:
|
||||
raise InstagramUploadError(f"Instagram 업로드 실패: {message}")
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.sns.models import SNSUploadTask
|
||||
|
||||
|
||||
class SNSUploadTaskAdmin(ModelView, model=SNSUploadTask):
|
||||
name = "SNS 업로드 작업"
|
||||
name_plural = "SNS 업로드 작업 목록"
|
||||
icon = "fa-solid fa-share-from-square"
|
||||
category = "SNS 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"task_id",
|
||||
"social_account_id",
|
||||
"is_scheduled",
|
||||
"status",
|
||||
"scheduled_at",
|
||||
"uploaded_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"task_id",
|
||||
"social_account_id",
|
||||
"is_scheduled",
|
||||
"scheduled_at",
|
||||
"url",
|
||||
"caption",
|
||||
"status",
|
||||
"uploaded_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at", "user", "social_account"]
|
||||
|
||||
column_searchable_list = [
|
||||
SNSUploadTask.user_uuid,
|
||||
SNSUploadTask.task_id,
|
||||
SNSUploadTask.status,
|
||||
]
|
||||
|
||||
column_default_sort = (SNSUploadTask.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
SNSUploadTask.id,
|
||||
SNSUploadTask.user_uuid,
|
||||
SNSUploadTask.social_account_id,
|
||||
SNSUploadTask.is_scheduled,
|
||||
SNSUploadTask.status,
|
||||
SNSUploadTask.scheduled_at,
|
||||
SNSUploadTask.uploaded_at,
|
||||
SNSUploadTask.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_uuid": "사용자 UUID",
|
||||
"task_id": "작업 ID",
|
||||
"social_account_id": "소셜 계정 ID",
|
||||
"is_scheduled": "예약 여부",
|
||||
"scheduled_at": "예약 일시",
|
||||
"url": "미디어 URL",
|
||||
"caption": "캡션",
|
||||
"status": "상태",
|
||||
"uploaded_at": "업로드 일시",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
"""
|
||||
SNS 모듈 SQLAlchemy 모델 정의
|
||||
|
||||
SNS 업로드 작업 관리 모델입니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.user.models import SocialAccount, User
|
||||
|
||||
|
||||
class SNSUploadTask(Base):
|
||||
"""
|
||||
SNS 업로드 작업 테이블
|
||||
|
||||
SNS 플랫폼에 콘텐츠를 업로드하는 작업을 관리합니다.
|
||||
즉시 업로드 또는 예약 업로드를 지원합니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||
task_id: 외부 작업 식별자 (비디오 생성 작업 등)
|
||||
is_scheduled: 예약 작업 여부 (True: 예약, False: 즉시)
|
||||
scheduled_at: 예약 발행 일시 (분 단위까지)
|
||||
social_account_id: 소셜 계정 외래키 (SocialAccount.id 참조)
|
||||
url: 업로드할 미디어 URL
|
||||
caption: 게시물 캡션/설명
|
||||
status: 발행 상태 (pending: 예약 대기, completed: 완료, error: 에러)
|
||||
uploaded_at: 실제 업로드 완료 일시
|
||||
created_at: 작업 생성 일시
|
||||
|
||||
발행 상태 (status):
|
||||
- pending: 예약 대기 중 (예약 작업이거나 처리 전)
|
||||
- processing: 처리 중
|
||||
- completed: 발행 완료
|
||||
- error: 에러 발생
|
||||
|
||||
Relationships:
|
||||
user: 작업 소유 사용자 (User 테이블 참조)
|
||||
social_account: 발행 대상 소셜 계정 (SocialAccount 테이블 참조)
|
||||
"""
|
||||
|
||||
__tablename__ = "sns_upload_task"
|
||||
__table_args__ = (
|
||||
Index("idx_sns_upload_task_user_uuid", "user_uuid"),
|
||||
Index("idx_sns_upload_task_task_id", "task_id"),
|
||||
Index("idx_sns_upload_task_social_account_id", "social_account_id"),
|
||||
Index("idx_sns_upload_task_status", "status"),
|
||||
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
|
||||
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
|
||||
Index("idx_sns_upload_task_created_at", "created_at"),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
},
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 기본 식별자
|
||||
# ==========================================================================
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
autoincrement=True,
|
||||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 사용자 및 작업 식별
|
||||
# ==========================================================================
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 UUID (User.user_uuid 참조)",
|
||||
)
|
||||
|
||||
task_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="외부 작업 식별자 (비디오 생성 작업 ID 등)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 예약 설정
|
||||
# ==========================================================================
|
||||
is_scheduled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="예약 작업 여부 (True: 예약 발행, False: 즉시 발행)",
|
||||
)
|
||||
|
||||
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="예약 발행 일시 (분 단위까지 지정)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 소셜 계정 연결
|
||||
# ==========================================================================
|
||||
social_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("social_account.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="소셜 계정 외래키 (SocialAccount.id 참조)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 업로드 콘텐츠
|
||||
# ==========================================================================
|
||||
url: Mapped[str] = mapped_column(
|
||||
String(2048),
|
||||
nullable=False,
|
||||
comment="업로드할 미디어 URL",
|
||||
)
|
||||
|
||||
caption: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="게시물 캡션/설명",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 발행 상태
|
||||
# ==========================================================================
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
comment="발행 상태 (pending: 예약 대기, processing: 처리 중, completed: 완료, error: 에러)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 시간 정보
|
||||
# ==========================================================================
|
||||
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="실제 업로드 완료 일시",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="작업 생성 일시",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Relationships
|
||||
# ==========================================================================
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[user_uuid],
|
||||
primaryjoin="SNSUploadTask.user_uuid == User.user_uuid",
|
||||
)
|
||||
|
||||
social_account: Mapped["SocialAccount"] = relationship(
|
||||
"SocialAccount",
|
||||
foreign_keys=[social_account_id],
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<SNSUploadTask("
|
||||
f"id={self.id}, "
|
||||
f"user_uuid='{self.user_uuid}', "
|
||||
f"social_account_id={self.social_account_id}, "
|
||||
f"status='{self.status}', "
|
||||
f"is_scheduled={self.is_scheduled}"
|
||||
f")>"
|
||||
)
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
"""
|
||||
SNS API Schemas
|
||||
|
||||
Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class InstagramUploadRequest(BaseModel):
|
||||
"""Instagram 업로드 요청 스키마
|
||||
|
||||
Usage:
|
||||
POST /sns/instagram/upload/{task_id}
|
||||
Instagram에 비디오를 업로드합니다.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"caption": "Test video from Instagram POC #test",
|
||||
"share_to_feed": true
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"caption": "Test video from Instagram POC #test",
|
||||
"share_to_feed": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
caption: str = Field(
|
||||
default="",
|
||||
description="게시물 캡션",
|
||||
max_length=2200,
|
||||
)
|
||||
share_to_feed: bool = Field(
|
||||
default=True,
|
||||
description="피드에 공유 여부",
|
||||
)
|
||||
|
||||
|
||||
class InstagramUploadResponse(BaseModel):
|
||||
"""Instagram 업로드 응답 스키마
|
||||
|
||||
Usage:
|
||||
POST /sns/instagram/upload/{task_id}
|
||||
Instagram 업로드 작업의 결과를 반환합니다.
|
||||
|
||||
Example Response (성공):
|
||||
{
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"state": "completed",
|
||||
"message": "Instagram 업로드 완료",
|
||||
"media_id": "17841405822304914",
|
||||
"permalink": "https://www.instagram.com/p/ABC123/",
|
||||
"error": null
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"state": "completed",
|
||||
"message": "Instagram 업로드 완료",
|
||||
"media_id": "17841405822304914",
|
||||
"permalink": "https://www.instagram.com/p/ABC123/",
|
||||
"error": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
state: str = Field(..., description="업로드 상태 (pending, processing, completed, failed)")
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
media_id: Optional[str] = Field(default=None, description="Instagram 미디어 ID (성공 시)")
|
||||
permalink: Optional[str] = Field(default=None, description="Instagram 게시물 URL (성공 시)")
|
||||
error: Optional[str] = Field(default=None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class Media(BaseModel):
|
||||
"""Instagram 미디어 정보"""
|
||||
|
||||
id: str
|
||||
media_type: Optional[str] = None
|
||||
media_url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None
|
||||
caption: Optional[str] = None
|
||||
timestamp: Optional[datetime] = None
|
||||
permalink: Optional[str] = None
|
||||
like_count: int = 0
|
||||
comments_count: int = 0
|
||||
children: Optional[list["Media"]] = None
|
||||
|
||||
|
||||
class MediaContainer(BaseModel):
|
||||
"""미디어 컨테이너 상태"""
|
||||
|
||||
id: str
|
||||
status_code: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_finished(self) -> bool:
|
||||
return self.status_code == "FINISHED"
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
return self.status_code == "ERROR"
|
||||
|
||||
@property
|
||||
def is_in_progress(self) -> bool:
|
||||
return self.status_code == "IN_PROGRESS"
|
||||
|
||||
|
||||
class APIError(BaseModel):
|
||||
"""API 에러 응답"""
|
||||
|
||||
message: str
|
||||
type: Optional[str] = None
|
||||
code: Optional[int] = None
|
||||
error_subcode: Optional[int] = None
|
||||
fbtrace_id: Optional[str] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""에러 응답 래퍼"""
|
||||
|
||||
error: APIError
|
||||
|
|
@ -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.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"]
|
||||
|
|
|
|||
|
|
@ -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": "업로드 작업이 시작되었습니다."}
|
||||
|
|
@ -238,7 +238,7 @@ async def get_account_by_platform(
|
|||
|
||||
raise SocialAccountNotFoundError(platform=platform.value)
|
||||
|
||||
return social_account_service.to_response(account)
|
||||
return social_account_service._to_response(account)
|
||||
|
||||
|
||||
@router.delete(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -2,34 +2,37 @@
|
|||
소셜 업로드 API 라우터
|
||||
|
||||
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
||||
비즈니스 로직은 SocialUploadService에 위임합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 (
|
||||
MessageResponse,
|
||||
SocialUploadHistoryItem,
|
||||
SocialUploadHistoryResponse,
|
||||
SocialUploadRequest,
|
||||
SocialUploadResponse,
|
||||
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.models import User
|
||||
from app.video.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
||||
|
||||
upload_service = SocialUploadService(account_service=social_account_service)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
|
|
@ -66,7 +69,111 @@ async def upload_to_social(
|
|||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> 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(
|
||||
|
|
@ -80,35 +187,116 @@ async def get_upload_status(
|
|||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> 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(
|
||||
"/history",
|
||||
response_model=SocialUploadHistoryResponse,
|
||||
summary="업로드 이력 조회",
|
||||
description="""
|
||||
사용자의 소셜 미디어 업로드 이력을 조회합니다.
|
||||
|
||||
## tab 파라미터
|
||||
- `all`: 전체 (기본값)
|
||||
- `completed`: 완료된 업로드
|
||||
- `scheduled`: 예약 업로드 (pending + scheduled_at 있음)
|
||||
- `failed`: 실패한 업로드
|
||||
""",
|
||||
description="사용자의 소셜 미디어 업로드 이력을 조회합니다.",
|
||||
)
|
||||
async def get_upload_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tab: str = Query("all", description="탭 필터 (all/completed/scheduled/failed)"),
|
||||
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
|
||||
year: Optional[int] = Query(None, description="조회 연도 (없으면 현재 연도)"),
|
||||
month: Optional[int] = Query(None, ge=1, le=12, description="조회 월 (없으면 현재 월)"),
|
||||
status: Optional[UploadStatus] = Query(None, description="상태 필터"),
|
||||
page: int = Query(1, ge=1, description="페이지 번호"),
|
||||
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
||||
) -> 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),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> 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(
|
||||
|
|
@ -138,4 +372,42 @@ async def cancel_upload(
|
|||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> 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="업로드가 취소되었습니다.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -91,12 +91,9 @@ PLATFORM_CONFIG = {
|
|||
YOUTUBE_SCOPES = [
|
||||
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
|
||||
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
|
||||
"https://www.googleapis.com/auth/yt-analytics.readonly", # 대시보드
|
||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||
]
|
||||
|
||||
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
|
||||
|
||||
# =============================================================================
|
||||
# Instagram/Facebook OAuth Scopes (추후 구현)
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ class TokenExpiredError(OAuthException):
|
|||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_EXPIRED",
|
||||
)
|
||||
self.platform = platform
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ class SocialUpload(Base):
|
|||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||
video_id: Video 외래키
|
||||
social_account_id: SocialAccount 외래키
|
||||
upload_seq: 업로드 순번 (동일 영상+채널 조합 내 순번, 관리자 추적용)
|
||||
platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
|
||||
status: 업로드 상태 (pending, uploading, processing, completed, failed)
|
||||
upload_progress: 업로드 진행률 (0-100)
|
||||
|
|
@ -59,10 +58,12 @@ class SocialUpload(Base):
|
|||
Index("idx_social_upload_platform", "platform"),
|
||||
Index("idx_social_upload_status", "status"),
|
||||
Index("idx_social_upload_created_at", "created_at"),
|
||||
# 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능)
|
||||
Index("idx_social_upload_video_account", "video_id", "social_account_id"),
|
||||
# 순번 조회용 인덱스
|
||||
Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"),
|
||||
Index(
|
||||
"uq_social_upload_video_platform",
|
||||
"video_id",
|
||||
"social_account_id",
|
||||
unique=True,
|
||||
),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
|
|
@ -105,16 +106,6 @@ class SocialUpload(Base):
|
|||
comment="SocialAccount 외래키",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 업로드 순번 (관리자 추적용)
|
||||
# ==========================================================================
|
||||
upload_seq: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=1,
|
||||
comment="업로드 순번 (동일 영상+채널 조합 내 순번, 1부터 시작)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 플랫폼 정보
|
||||
# ==========================================================================
|
||||
|
|
@ -190,15 +181,6 @@ class SocialUpload(Base):
|
|||
comment="플랫폼별 추가 옵션 (JSON)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 예약 게시 시간
|
||||
# ==========================================================================
|
||||
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="예약 게시 시간 (스케줄러가 이 시간 이후에 업로드 실행)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 에러 정보
|
||||
# ==========================================================================
|
||||
|
|
@ -256,10 +238,8 @@ class SocialUpload(Base):
|
|||
return (
|
||||
f"<SocialUpload("
|
||||
f"id={self.id}, "
|
||||
f"video_id={self.video_id}, "
|
||||
f"account_id={self.social_account_id}, "
|
||||
f"seq={self.upload_seq}, "
|
||||
f"platform='{self.platform}', "
|
||||
f"status='{self.status}'"
|
||||
f"status='{self.status}', "
|
||||
f"video_id={self.video_id}"
|
||||
f")>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient):
|
|||
"response_type": "code",
|
||||
"scope": " ".join(YOUTUBE_SCOPES),
|
||||
"access_type": "offline", # refresh_token 받기 위해 필요
|
||||
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
|
||||
"prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지)
|
||||
"state": state,
|
||||
}
|
||||
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""
|
||||
소셜 업로드 관련 Pydantic 스키마
|
||||
Social Media Schemas
|
||||
|
||||
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
|
@ -7,7 +9,123 @@ from typing import Any, Optional
|
|||
|
||||
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):
|
||||
|
|
@ -41,7 +159,7 @@ class SocialUploadRequest(BaseModel):
|
|||
"privacy_status": "public",
|
||||
"scheduled_at": "2026-02-02T15:00:00",
|
||||
"platform_options": {
|
||||
"category_id": "22",
|
||||
"category_id": "22", # YouTube 카테고리
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -75,8 +193,6 @@ class SocialUploadStatusResponse(BaseModel):
|
|||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
video_id: int = Field(..., description="영상 ID")
|
||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
status: UploadStatus = Field(..., description="업로드 상태")
|
||||
upload_progress: int = Field(..., description="업로드 진행률 (0-100)")
|
||||
|
|
@ -85,7 +201,6 @@ class SocialUploadStatusResponse(BaseModel):
|
|||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지")
|
||||
retry_count: int = Field(default=0, description="재시도 횟수")
|
||||
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간 (있으면 예약 업로드)")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
|
||||
|
||||
|
|
@ -95,8 +210,6 @@ class SocialUploadStatusResponse(BaseModel):
|
|||
"example": {
|
||||
"upload_id": 456,
|
||||
"video_id": 123,
|
||||
"social_account_id": 1,
|
||||
"upload_seq": 2,
|
||||
"platform": "youtube",
|
||||
"status": "completed",
|
||||
"upload_progress": 100,
|
||||
|
|
@ -117,14 +230,10 @@ class SocialUploadHistoryItem(BaseModel):
|
|||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
video_id: int = Field(..., description="영상 ID")
|
||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
status: str = Field(..., description="업로드 상태")
|
||||
title: str = Field(..., description="영상 제목")
|
||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지")
|
||||
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")
|
||||
created_at: datetime = Field(..., 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": "작업이 완료되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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": "작업이 완료되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -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": ["여기에", "더미", "해시태그"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -4,15 +4,12 @@ Social Account Service
|
|||
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.utils.timezone import now
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
|
@ -28,10 +25,10 @@ redis_client = Redis(
|
|||
decode_responses=True,
|
||||
)
|
||||
from app.social.exceptions import (
|
||||
InvalidStateError,
|
||||
OAuthStateExpiredError,
|
||||
OAuthTokenRefreshError,
|
||||
SocialAccountAlreadyConnectedError,
|
||||
SocialAccountNotFoundError,
|
||||
TokenExpiredError,
|
||||
)
|
||||
from app.social.oauth import get_oauth_client
|
||||
from app.social.schemas import (
|
||||
|
|
@ -89,7 +86,7 @@ class SocialAccountService:
|
|||
await redis_client.setex(
|
||||
state_key,
|
||||
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
|
||||
json.dumps(state_data), # JSON으로 직렬화
|
||||
str(state_data),
|
||||
)
|
||||
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
|
||||
|
||||
|
|
@ -125,7 +122,9 @@ class SocialAccountService:
|
|||
SocialAccountResponse: 연동된 소셜 계정 정보
|
||||
|
||||
Raises:
|
||||
OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우
|
||||
InvalidStateError: state 토큰이 유효하지 않은 경우
|
||||
OAuthStateExpiredError: state 토큰이 만료된 경우
|
||||
SocialAccountAlreadyConnectedError: 이미 연동된 계정인 경우
|
||||
"""
|
||||
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
|
||||
|
||||
|
|
@ -137,8 +136,8 @@ class SocialAccountService:
|
|||
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
|
||||
raise OAuthStateExpiredError()
|
||||
|
||||
# state 데이터 파싱 (JSON 역직렬화)
|
||||
state_data = json.loads(state_data_str)
|
||||
# state 데이터 파싱
|
||||
state_data = eval(state_data_str) # {"user_uuid": "...", "platform": "..."}
|
||||
user_uuid = state_data["user_uuid"]
|
||||
platform = SocialPlatform(state_data["platform"])
|
||||
|
||||
|
|
@ -188,7 +187,7 @@ class SocialAccountService:
|
|||
session=session,
|
||||
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
|
||||
)
|
||||
return self.to_response(existing_account)
|
||||
return self._to_response(existing_account)
|
||||
|
||||
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
|
||||
social_account = await self._create_social_account(
|
||||
|
|
@ -204,21 +203,19 @@ class SocialAccountService:
|
|||
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(
|
||||
self,
|
||||
user_uuid: str,
|
||||
session: AsyncSession,
|
||||
auto_refresh: bool = True,
|
||||
) -> list[SocialAccountResponse]:
|
||||
"""
|
||||
연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함)
|
||||
연동된 소셜 계정 목록 조회
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
session: DB 세션
|
||||
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
|
||||
|
||||
Returns:
|
||||
list[SocialAccountResponse]: 연동된 계정 목록
|
||||
|
|
@ -236,96 +233,7 @@ class SocialAccountService:
|
|||
|
||||
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
|
||||
|
||||
# 토큰 자동 갱신
|
||||
if auto_refresh:
|
||||
for account in accounts:
|
||||
await self._try_refresh_token(account, session)
|
||||
|
||||
return [self.to_response(account) for account in accounts]
|
||||
|
||||
async def refresh_all_tokens(
|
||||
self,
|
||||
user_uuid: str,
|
||||
session: AsyncSession,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
사용자의 모든 연동 계정 토큰 갱신 (로그인 시 호출)
|
||||
|
||||
Args:
|
||||
user_uuid: 사용자 UUID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
dict[str, bool]: 플랫폼별 갱신 성공 여부
|
||||
"""
|
||||
logger.info(f"[SOCIAL] 모든 연동 계정 토큰 갱신 시작 - user_uuid: {user_uuid}")
|
||||
|
||||
result = await session.execute(
|
||||
select(SocialAccount).where(
|
||||
SocialAccount.user_uuid == user_uuid,
|
||||
SocialAccount.is_active == True, # noqa: E712
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
|
||||
refresh_results = {}
|
||||
for account in accounts:
|
||||
success = await self._try_refresh_token(account, session)
|
||||
refresh_results[f"{account.platform}_{account.id}"] = success
|
||||
|
||||
logger.info(f"[SOCIAL] 토큰 갱신 완료 - results: {refresh_results}")
|
||||
return refresh_results
|
||||
|
||||
async def _try_refresh_token(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
session: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
토큰 갱신 시도 (실패해도 예외 발생하지 않음)
|
||||
|
||||
Args:
|
||||
account: 소셜 계정
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
bool: 갱신 성공 여부
|
||||
"""
|
||||
# refresh_token이 없으면 갱신 불가
|
||||
if not account.refresh_token:
|
||||
logger.debug(
|
||||
f"[SOCIAL] refresh_token 없음, 갱신 스킵 - account_id: {account.id}"
|
||||
)
|
||||
return False
|
||||
|
||||
# 만료 시간 확인 (만료 1시간 전이면 갱신)
|
||||
should_refresh = False
|
||||
if account.token_expires_at is None:
|
||||
should_refresh = True
|
||||
else:
|
||||
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
|
||||
current_time = now().replace(tzinfo=None)
|
||||
buffer_time = current_time + timedelta(hours=1)
|
||||
if account.token_expires_at <= buffer_time:
|
||||
should_refresh = True
|
||||
|
||||
if not should_refresh:
|
||||
logger.debug(
|
||||
f"[SOCIAL] 토큰 아직 유효, 갱신 스킵 - account_id: {account.id}"
|
||||
)
|
||||
return True
|
||||
|
||||
# 갱신 시도
|
||||
try:
|
||||
await self._refresh_account_token(account, session)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[SOCIAL] 토큰 갱신 실패 (재연동 필요) - "
|
||||
f"account_id: {account.id}, error: {e}"
|
||||
)
|
||||
return False
|
||||
return [self._to_response(account) for account in accounts]
|
||||
|
||||
async def get_account_by_platform(
|
||||
self,
|
||||
|
|
@ -494,38 +402,18 @@ class SocialAccountService:
|
|||
|
||||
Returns:
|
||||
str: 유효한 access_token
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요)
|
||||
"""
|
||||
# 만료 시간 확인
|
||||
is_expired = False
|
||||
if account.token_expires_at is None:
|
||||
is_expired = True
|
||||
else:
|
||||
current_time = now().replace(tzinfo=None)
|
||||
buffer_time = current_time + timedelta(minutes=10)
|
||||
# 만료 시간 확인 (만료 10분 전이면 갱신)
|
||||
if account.token_expires_at:
|
||||
buffer_time = datetime.now() + timedelta(minutes=10)
|
||||
if account.token_expires_at <= buffer_time:
|
||||
is_expired = True
|
||||
|
||||
# 아직 유효하면 그대로 사용
|
||||
if not is_expired:
|
||||
return account.access_token
|
||||
|
||||
# 만료됐는데 refresh_token이 없으면 재연동 필요
|
||||
if not account.refresh_token:
|
||||
logger.warning(
|
||||
f"[SOCIAL] access_token 만료 + refresh_token 없음, 재연동 필요 - "
|
||||
f"account_id: {account.id}"
|
||||
)
|
||||
raise TokenExpiredError(platform=account.platform)
|
||||
|
||||
# refresh_token으로 갱신
|
||||
logger.info(
|
||||
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
||||
)
|
||||
return await self._refresh_account_token(account, session)
|
||||
|
||||
return account.access_token
|
||||
|
||||
async def _refresh_account_token(
|
||||
self,
|
||||
account: SocialAccount,
|
||||
|
|
@ -540,46 +428,28 @@ class SocialAccountService:
|
|||
|
||||
Returns:
|
||||
str: 새 access_token
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: 갱신 실패 시 (재연동 필요)
|
||||
"""
|
||||
if not account.refresh_token:
|
||||
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)
|
||||
oauth_client = get_oauth_client(platform)
|
||||
|
||||
try:
|
||||
token_response = await oauth_client.refresh_token(account.refresh_token)
|
||||
except OAuthTokenRefreshError as e:
|
||||
logger.error(
|
||||
f"[SOCIAL] 토큰 갱신 실패, 재연동 필요 - "
|
||||
f"account_id: {account.id}, error: {e}"
|
||||
)
|
||||
raise TokenExpiredError(platform=account.platform)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[SOCIAL] 토큰 갱신 중 예외 발생, 재연동 필요 - "
|
||||
f"account_id: {account.id}, error: {e}"
|
||||
)
|
||||
raise TokenExpiredError(platform=account.platform)
|
||||
|
||||
# 토큰 업데이트
|
||||
account.access_token = token_response.access_token
|
||||
if token_response.refresh_token:
|
||||
account.refresh_token = token_response.refresh_token
|
||||
if token_response.expires_in:
|
||||
# DB에 naive datetime으로 저장 (MySQL DateTime은 timezone 미지원)
|
||||
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||
account.token_expires_at = datetime.now() + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
|
||||
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
|
||||
return account.access_token
|
||||
|
|
@ -633,10 +503,10 @@ class SocialAccountService:
|
|||
Returns:
|
||||
SocialAccount: 생성된 소셜 계정
|
||||
"""
|
||||
# 토큰 만료 시간 계산 (DB에 naive datetime으로 저장)
|
||||
# 토큰 만료 시간 계산
|
||||
token_expires_at = None
|
||||
if token_response.expires_in:
|
||||
token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||
token_expires_at = datetime.now() + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
|
||||
|
|
@ -689,8 +559,7 @@ class SocialAccountService:
|
|||
if token_response.refresh_token:
|
||||
account.refresh_token = token_response.refresh_token
|
||||
if token_response.expires_in:
|
||||
# DB에 naive datetime으로 저장
|
||||
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||
account.token_expires_at = datetime.now() + timedelta(
|
||||
seconds=token_response.expires_in
|
||||
)
|
||||
if token_response.scope:
|
||||
|
|
@ -706,14 +575,14 @@ class SocialAccountService:
|
|||
|
||||
# 재연결 시 연결 시간 업데이트
|
||||
if update_connected_at:
|
||||
account.connected_at = now().replace(tzinfo=None)
|
||||
account.connected_at = datetime.now()
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
|
||||
return account
|
||||
|
||||
def to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
||||
def _to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
||||
"""
|
||||
SocialAccount를 SocialAccountResponse로 변환
|
||||
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
"""
|
||||
소셜 서비스 베이스 클래스
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class BaseService:
|
||||
"""서비스 레이어 베이스 클래스"""
|
||||
|
||||
def __init__(self, session: AsyncSession | None = None):
|
||||
self.session = session
|
||||
|
|
@ -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()
|
||||
|
|
@ -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="업로드가 취소되었습니다.",
|
||||
)
|
||||
|
|
@ -7,21 +7,19 @@ Social Upload Background Task
|
|||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
|
||||
from app.utils.timezone import now
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from config import social_upload_settings
|
||||
from app.dashboard.tasks import insert_dashboard
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
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.services import social_account_service
|
||||
from app.social.uploader import get_uploader
|
||||
|
|
@ -72,7 +70,7 @@ async def _update_upload_status(
|
|||
if error_message:
|
||||
upload.error_message = error_message
|
||||
if status == UploadStatus.COMPLETED:
|
||||
upload.uploaded_at = now().replace(tzinfo=None)
|
||||
upload.uploaded_at = datetime.now()
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
|
|
@ -319,7 +317,6 @@ async def process_social_upload(upload_id: int) -> None:
|
|||
f"platform_video_id: {result.platform_video_id}, "
|
||||
f"url: {result.platform_url}"
|
||||
)
|
||||
await insert_dashboard(upload_id)
|
||||
else:
|
||||
retry_count = await _increment_retry_count(upload_id)
|
||||
|
||||
|
|
@ -355,17 +352,6 @@ async def process_social_upload(upload_id: int) -> None:
|
|||
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
||||
)
|
||||
|
||||
except TokenExpiredError as e:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 토큰 만료, 재연동 필요 - "
|
||||
f"upload_id: {upload_id}, platform: {e.platform}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
status=UploadStatus.FAILED,
|
||||
error_message=f"{e.platform} 계정 인증이 만료되었습니다. 계정을 다시 연동해주세요.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
|
||||
|
|
|
|||
|
|
@ -415,6 +415,13 @@ async def get_song_status(
|
|||
|
||||
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
||||
if song and song.status == "processing":
|
||||
# store_name 조회
|
||||
project_result = await session.execute(
|
||||
select(Project).where(Project.id == song.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
store_name = project.store_name if project else "song"
|
||||
|
||||
# 상태를 uploading으로 변경 (중복 호출 방지)
|
||||
song.status = "uploading"
|
||||
song.suno_audio_id = first_clip.get("id")
|
||||
|
|
@ -428,11 +435,12 @@ async def get_song_status(
|
|||
download_and_upload_song_by_suno_task_id,
|
||||
suno_task_id=song_id,
|
||||
audio_url=audio_url,
|
||||
store_name=store_name,
|
||||
user_uuid=current_user.user_uuid,
|
||||
duration=clip_duration,
|
||||
)
|
||||
logger.info(
|
||||
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}"
|
||||
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}"
|
||||
)
|
||||
|
||||
suno_audio_id = first_clip.get("id")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
|
@ -104,6 +107,21 @@ class GenerateSongResponse(BaseModel):
|
|||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class PollingSongRequest(BaseModel):
|
||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
||||
|
||||
Note:
|
||||
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"task_id": "abc123..."
|
||||
}
|
||||
"""
|
||||
|
||||
task_id: str = Field(..., description="Suno 작업 ID")
|
||||
|
||||
|
||||
class SongClipData(BaseModel):
|
||||
"""생성된 노래 클립 정보"""
|
||||
|
||||
|
|
@ -216,3 +234,94 @@ class PollingSongResponse(BaseModel):
|
|||
song_result_url: Optional[str] = Field(
|
||||
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dataclass Schemas (Legacy)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreData:
|
||||
id: int
|
||||
created_at: datetime
|
||||
store_name: str
|
||||
store_category: str | None = None
|
||||
store_region: str | None = None
|
||||
store_address: str | None = None
|
||||
store_phone_number: str | None = None
|
||||
store_info: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeData:
|
||||
id: int
|
||||
attr_category: str
|
||||
attr_value: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongSampleData:
|
||||
id: int
|
||||
ai: str
|
||||
ai_model: str
|
||||
sample_song: str
|
||||
season: str | None = None
|
||||
num_of_people: int | None = None
|
||||
people_category: str | None = None
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplateData:
|
||||
id: int
|
||||
prompt: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongFormData:
|
||||
store_name: str
|
||||
store_id: str
|
||||
prompts: str
|
||||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-5-mini"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||
form_data = await request.form()
|
||||
|
||||
# 고정 필드명들
|
||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||
|
||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||
lyrics_ids = []
|
||||
attributes = {}
|
||||
|
||||
for key, value in form_data.items():
|
||||
if key.startswith("lyrics-"):
|
||||
lyrics_id = key.split("-")[1]
|
||||
lyrics_ids.append(int(lyrics_id))
|
||||
elif key not in fixed_keys:
|
||||
attributes[key] = value
|
||||
|
||||
# attributes를 문자열로 변환
|
||||
attributes_str = (
|
||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||
if attributes
|
||||
else ""
|
||||
)
|
||||
|
||||
return cls(
|
||||
store_name=form_data.get("store_info_name", ""),
|
||||
store_id=form_data.get("store_id", ""),
|
||||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ from sqlalchemy import Connection, text
|
|||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.lyric.schemas.lyrics_schema import (
|
||||
from app.lyrics.schemas.lyrics_schema import (
|
||||
AttributeData,
|
||||
PromptTemplateData,
|
||||
SongFormData,
|
||||
SongSampleData,
|
||||
StoreData,
|
||||
)
|
||||
from app.utils.prompts.chatgpt_prompt import chatgpt_api
|
||||
from app.utils.chatgpt_prompt import chatgpt_api
|
||||
|
||||
logger = get_logger("song")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Song Background Tasks
|
|||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import traceback
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
|
|
@ -13,8 +15,10 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.song.models import Song
|
||||
from app.utils.common import generate_task_id
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from config import prj_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("song")
|
||||
|
|
@ -114,23 +118,87 @@ async def _download_audio(url: str, task_id: str) -> bytes:
|
|||
return response.content
|
||||
|
||||
|
||||
async def download_and_save_song(
|
||||
task_id: str,
|
||||
audio_url: str,
|
||||
store_name: str,
|
||||
) -> None:
|
||||
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
||||
|
||||
Args:
|
||||
task_id: 프로젝트 task_id
|
||||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
"""
|
||||
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||
|
||||
try:
|
||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
unique_id = await generate_task_id()
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
safe_store_name = "".join(
|
||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||
).strip()
|
||||
safe_store_name = safe_store_name or "song"
|
||||
file_name = f"{safe_store_name}.mp3"
|
||||
|
||||
# 절대 경로 생성
|
||||
media_dir = Path("media") / "song" / today / unique_id
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = media_dir / file_name
|
||||
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||
|
||||
# 오디오 파일 다운로드
|
||||
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
|
||||
content = await _download_audio(audio_url, task_id)
|
||||
|
||||
async with aiofiles.open(str(file_path), "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||
|
||||
# 프론트엔드에서 접근 가능한 URL 생성
|
||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
||||
base_url = f"{prj_settings.PROJECT_DOMAIN}"
|
||||
file_url = f"{base_url}{relative_path}"
|
||||
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(task_id, "completed", file_url)
|
||||
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
|
||||
async def download_and_upload_song_by_suno_task_id(
|
||||
suno_task_id: str,
|
||||
audio_url: str,
|
||||
store_name: str,
|
||||
user_uuid: str,
|
||||
duration: float | None = None,
|
||||
) -> None:
|
||||
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||
|
||||
파일명은 suno_task_id를 사용하여 고유성을 보장합니다.
|
||||
|
||||
Args:
|
||||
suno_task_id: Suno API 작업 ID (파일명으로도 사용)
|
||||
suno_task_id: Suno API 작업 ID
|
||||
audio_url: 다운로드할 오디오 URL
|
||||
store_name: 저장할 파일명에 사용할 업체명
|
||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||
duration: 노래 재생 시간 (초)
|
||||
"""
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, duration: {duration}")
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
||||
temp_file_path: Path | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
|
|
@ -152,8 +220,12 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
task_id = song.task_id
|
||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||
|
||||
# suno_task_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
|
||||
file_name = f"{suno_task_id}.mp3"
|
||||
# 파일명에 사용할 수 없는 문자 제거
|
||||
safe_store_name = "".join(
|
||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
||||
).strip()
|
||||
safe_store_name = safe_store_name or "song"
|
||||
file_name = f"{safe_store_name}.mp3"
|
||||
|
||||
# 임시 저장 경로 생성
|
||||
temp_dir = Path("media") / "temp" / task_id
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@
|
|||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
|
||||
from app.utils.timezone import now
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
|
@ -23,23 +22,21 @@ logger = logging.getLogger(__name__)
|
|||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import RefreshToken, User
|
||||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoCodeRequest,
|
||||
KakaoLoginResponse,
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.user.services import auth_service, kakao_client
|
||||
from app.user.services.jwt import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_access_token_expire_seconds,
|
||||
get_refresh_token_expires_at,
|
||||
get_token_hash,
|
||||
)
|
||||
from app.social.services import social_account_service
|
||||
from app.utils.common import generate_uuid
|
||||
|
||||
|
||||
|
|
@ -143,19 +140,6 @@ async def kakao_callback(
|
|||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
|
||||
try:
|
||||
payload = decode_token(result.access_token)
|
||||
if payload and payload.get("sub"):
|
||||
user_uuid = payload.get("sub")
|
||||
await social_account_service.refresh_all_tokens(
|
||||
user_uuid=user_uuid,
|
||||
session=session,
|
||||
)
|
||||
except Exception as e:
|
||||
# 토큰 갱신 실패해도 로그인은 성공 처리
|
||||
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||
|
||||
# 프론트엔드로 토큰과 함께 리다이렉트
|
||||
redirect_url = (
|
||||
f"{prj_settings.PROJECT_DOMAIN}"
|
||||
|
|
@ -221,49 +205,32 @@ async def kakao_verify(
|
|||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
|
||||
try:
|
||||
payload = decode_token(result.access_token)
|
||||
if payload and payload.get("sub"):
|
||||
user_uuid = payload.get("sub")
|
||||
await social_account_service.refresh_all_tokens(
|
||||
user_uuid=user_uuid,
|
||||
session=session,
|
||||
logger.info(
|
||||
f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}"
|
||||
)
|
||||
except Exception as e:
|
||||
# 토큰 갱신 실패해도 로그인은 성공 처리
|
||||
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||
|
||||
logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - is_new_user: {result.is_new_user}")
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenResponse,
|
||||
summary="토큰 갱신 (Refresh Token Rotation)",
|
||||
description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.",
|
||||
response_model=AccessTokenResponse,
|
||||
summary="토큰 갱신",
|
||||
description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
|
||||
)
|
||||
async def refresh_token(
|
||||
body: RefreshTokenRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
) -> AccessTokenResponse:
|
||||
"""
|
||||
토큰 갱신 (Refresh Token Rotation)
|
||||
액세스 토큰 갱신
|
||||
|
||||
유효한 리프레시 토큰을 제출하면 새 액세스 토큰과 새 리프레시 토큰을 발급합니다.
|
||||
사용된 기존 리프레시 토큰은 즉시 폐기(revoke)됩니다.
|
||||
유효한 리프레시 토큰을 제출하면 새 액세스 토큰을 발급합니다.
|
||||
리프레시 토큰은 변경되지 않습니다.
|
||||
"""
|
||||
logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}")
|
||||
result = await auth_service.refresh_tokens(
|
||||
return await auth_service.refresh_tokens(
|
||||
refresh_token=body.refresh_token,
|
||||
session=session,
|
||||
)
|
||||
logger.info(
|
||||
f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, "
|
||||
f"new_refresh: ...{result.refresh_token[-20:]}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -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(
|
||||
user_id=current_user.id,
|
||||
refresh_token=body.refresh_token,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -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(
|
||||
user_id=current_user.id,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -477,7 +434,7 @@ async def generate_test_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()
|
||||
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -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="소셜 계정 삭제 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
|
@ -160,17 +160,16 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
|||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_username",
|
||||
"is_active",
|
||||
"is_deleted",
|
||||
"created_at",
|
||||
"connected_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_user_id",
|
||||
"platform_username",
|
||||
|
|
@ -178,34 +177,32 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
|||
"scope",
|
||||
"token_expires_at",
|
||||
"is_active",
|
||||
"is_deleted",
|
||||
"created_at",
|
||||
"connected_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at", "updated_at", "user"]
|
||||
form_excluded_columns = ["connected_at", "updated_at", "user"]
|
||||
|
||||
column_searchable_list = [
|
||||
SocialAccount.user_uuid,
|
||||
SocialAccount.user_id,
|
||||
SocialAccount.platform,
|
||||
SocialAccount.platform_user_id,
|
||||
SocialAccount.platform_username,
|
||||
]
|
||||
|
||||
column_default_sort = (SocialAccount.created_at, True)
|
||||
column_default_sort = (SocialAccount.connected_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
SocialAccount.id,
|
||||
SocialAccount.user_uuid,
|
||||
SocialAccount.user_id,
|
||||
SocialAccount.platform,
|
||||
SocialAccount.is_active,
|
||||
SocialAccount.is_deleted,
|
||||
SocialAccount.created_at,
|
||||
SocialAccount.connected_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_uuid": "사용자 UUID",
|
||||
"user_id": "사용자 ID",
|
||||
"platform": "플랫폼",
|
||||
"platform_user_id": "플랫폼 사용자 ID",
|
||||
"platform_username": "플랫폼 사용자명",
|
||||
|
|
@ -213,7 +210,6 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
|||
"scope": "권한 범위",
|
||||
"token_expires_at": "토큰 만료일시",
|
||||
"is_active": "활성화",
|
||||
"is_deleted": "삭제됨",
|
||||
"created_at": "생성일시",
|
||||
"connected_at": "연동일시",
|
||||
"updated_at": "수정일시",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends
|
||||
|
|
@ -13,18 +12,17 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.user.models import User
|
||||
from app.user.services.auth import (
|
||||
from app.user.exceptions import (
|
||||
AdminRequiredError,
|
||||
InvalidTokenError,
|
||||
MissingTokenError,
|
||||
TokenExpiredError,
|
||||
UserInactiveError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from app.user.models import User
|
||||
from app.user.services.jwt import decode_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
|
|
@ -50,28 +48,18 @@ async def get_current_user(
|
|||
UserInactiveError: 비활성화된 계정인 경우
|
||||
"""
|
||||
if credentials is None:
|
||||
logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError")
|
||||
raise MissingTokenError()
|
||||
|
||||
token = credentials.credentials
|
||||
logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}")
|
||||
|
||||
payload = decode_token(token)
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
|
||||
raise InvalidTokenError()
|
||||
|
||||
# 토큰 타입 확인
|
||||
if payload.get("type") != "access":
|
||||
logger.warning(
|
||||
f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, "
|
||||
f"got: {payload.get('type')}, sub: {payload.get('sub')}"
|
||||
)
|
||||
raise InvalidTokenError("액세스 토큰이 아닙니다.")
|
||||
|
||||
user_uuid = payload.get("sub")
|
||||
if user_uuid is None:
|
||||
logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}")
|
||||
raise InvalidTokenError()
|
||||
|
||||
# 사용자 조회
|
||||
|
|
@ -84,18 +72,11 @@ async def get_current_user(
|
|||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}")
|
||||
raise UserNotFoundError()
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||
)
|
||||
raise UserInactiveError()
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
|
@ -116,24 +97,17 @@ async def get_current_user_optional(
|
|||
User | None: 로그인한 사용자 또는 None
|
||||
"""
|
||||
if credentials is None:
|
||||
logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음")
|
||||
return None
|
||||
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}")
|
||||
return None
|
||||
|
||||
if payload.get("type") != "access":
|
||||
logger.debug(
|
||||
f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})"
|
||||
)
|
||||
return None
|
||||
|
||||
user_uuid = payload.get("sub")
|
||||
if user_uuid is None:
|
||||
logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음")
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
|
|
@ -145,14 +119,8 @@ async def get_current_user_optional(
|
|||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not user.is_active:
|
||||
logger.debug(
|
||||
f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
User 모듈 커스텀 예외 정의
|
||||
|
||||
인증 및 사용자 관련 에러를 처리하기 위한 예외 클래스들입니다.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class AuthException(HTTPException):
|
||||
"""인증 관련 기본 예외"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
code: str,
|
||||
message: str,
|
||||
):
|
||||
super().__init__(
|
||||
status_code=status_code,
|
||||
detail={"code": code, "message": message},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 카카오 OAuth 관련 예외
|
||||
# =============================================================================
|
||||
class InvalidAuthCodeError(AuthException):
|
||||
"""유효하지 않은 인가 코드"""
|
||||
|
||||
def __init__(self, message: str = "유효하지 않은 인가 코드입니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="INVALID_CODE",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class KakaoAuthFailedError(AuthException):
|
||||
"""카카오 인증 실패"""
|
||||
|
||||
def __init__(self, message: str = "카카오 인증에 실패했습니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="KAKAO_AUTH_FAILED",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class KakaoAPIError(AuthException):
|
||||
"""카카오 API 호출 오류"""
|
||||
|
||||
def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="KAKAO_API_ERROR",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JWT 토큰 관련 예외
|
||||
# =============================================================================
|
||||
class TokenExpiredError(AuthException):
|
||||
"""토큰 만료"""
|
||||
|
||||
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_EXPIRED",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class InvalidTokenError(AuthException):
|
||||
"""유효하지 않은 토큰"""
|
||||
|
||||
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="INVALID_TOKEN",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class TokenRevokedError(AuthException):
|
||||
"""취소된 토큰"""
|
||||
|
||||
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="TOKEN_REVOKED",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class MissingTokenError(AuthException):
|
||||
"""토큰 누락"""
|
||||
|
||||
def __init__(self, message: str = "인증 토큰이 필요합니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
code="MISSING_TOKEN",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 사용자 관련 예외
|
||||
# =============================================================================
|
||||
class UserNotFoundError(AuthException):
|
||||
"""사용자 없음"""
|
||||
|
||||
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="USER_NOT_FOUND",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class UserInactiveError(AuthException):
|
||||
"""비활성화된 계정"""
|
||||
|
||||
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
code="USER_INACTIVE",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class AdminRequiredError(AuthException):
|
||||
"""관리자 권한 필요"""
|
||||
|
||||
def __init__(self, message: str = "관리자 권한이 필요합니다."):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
code="ADMIN_REQUIRED",
|
||||
message=message,
|
||||
)
|
||||
|
|
@ -5,7 +5,6 @@ User 모듈 SQLAlchemy 모델 정의
|
|||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
|
|
@ -14,7 +13,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||
|
||||
from app.database.session import Base
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.home.models import Project
|
||||
|
||||
|
|
@ -344,6 +342,7 @@ class RefreshToken(Base):
|
|||
token_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="리프레시 토큰 SHA-256 해시값",
|
||||
)
|
||||
|
||||
|
|
@ -391,7 +390,6 @@ class RefreshToken(Base):
|
|||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="refresh_tokens",
|
||||
lazy="selectin", # lazy loading 방지
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
소셜 계정 연동 테이블
|
||||
|
|
@ -486,10 +475,10 @@ class SocialAccount(Base):
|
|||
# ==========================================================================
|
||||
# 플랫폼 구분
|
||||
# ==========================================================================
|
||||
platform: Mapped[Platform] = mapped_column(
|
||||
platform: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)",
|
||||
comment="플랫폼 구분 (youtube, instagram, facebook)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
|
|
@ -522,9 +511,9 @@ class SocialAccount(Base):
|
|||
# ==========================================================================
|
||||
# 플랫폼 계정 식별 정보
|
||||
# ==========================================================================
|
||||
platform_user_id: Mapped[Optional[str]] = mapped_column(
|
||||
platform_user_id: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
comment="플랫폼 내 사용자 고유 ID",
|
||||
)
|
||||
|
||||
|
|
@ -550,7 +539,7 @@ class SocialAccount(Base):
|
|||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="활성화 상태 (비활성화 시 사용 중지)",
|
||||
comment="연동 활성화 상태 (비활성화 시 사용 중지)",
|
||||
)
|
||||
|
||||
is_deleted: Mapped[bool] = mapped_column(
|
||||
|
|
@ -565,10 +554,9 @@ class SocialAccount(Base):
|
|||
# ==========================================================================
|
||||
connected_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
comment="연결 일시",
|
||||
comment="연동 일시",
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
|
@ -579,20 +567,12 @@ class SocialAccount(Base):
|
|||
comment="정보 수정 일시",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# User 관계
|
||||
# ==========================================================================
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="social_accounts",
|
||||
lazy="selectin", # lazy loading 방지
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoCodeRequest,
|
||||
KakaoLoginResponse,
|
||||
KakaoTokenResponse,
|
||||
|
|
@ -11,6 +12,7 @@ from app.user.schemas.user_schema import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"AccessTokenResponse",
|
||||
"KakaoCodeRequest",
|
||||
"KakaoLoginResponse",
|
||||
"KakaoTokenResponse",
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
"""
|
||||
SocialAccount 모듈 Pydantic 스키마 정의
|
||||
|
||||
소셜 계정 연동 API 요청/응답 검증을 위한 스키마들입니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.user.models import Platform
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 요청 스키마
|
||||
# =============================================================================
|
||||
class SocialAccountCreateRequest(BaseModel):
|
||||
"""소셜 계정 연동 요청"""
|
||||
|
||||
platform: Platform = Field(..., description="플랫폼 구분 (youtube, instagram, facebook, tiktok)")
|
||||
access_token: str = Field(..., min_length=1, description="OAuth 액세스 토큰")
|
||||
refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰")
|
||||
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||
platform_user_id: Optional[str] = Field(None, description="플랫폼 내 사용자 고유 ID")
|
||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"platform": "instagram",
|
||||
"access_token": "IGQWRPcG...",
|
||||
"refresh_token": None,
|
||||
"token_expires_at": None,
|
||||
"scope": None,
|
||||
"platform_user_id": None,
|
||||
"platform_username": None,
|
||||
"platform_data": None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountUpdateRequest(BaseModel):
|
||||
"""소셜 계정 정보 수정 요청"""
|
||||
|
||||
access_token: Optional[str] = Field(None, min_length=1, description="OAuth 액세스 토큰")
|
||||
refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰")
|
||||
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||
is_active: Optional[bool] = Field(None, description="활성화 상태")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"access_token": "IGQWRPcG_NEW_TOKEN...",
|
||||
"token_expires_at": "2026-04-15T10:30:00",
|
||||
"is_active": True
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 응답 스키마
|
||||
# =============================================================================
|
||||
class SocialAccountResponse(BaseModel):
|
||||
"""소셜 계정 정보 응답"""
|
||||
|
||||
account_id: int = Field(..., validation_alias="id", description="소셜 계정 ID")
|
||||
platform: Platform = Field(..., description="플랫폼 구분")
|
||||
platform_user_id: Optional[str] = Field(None, description="플랫폼 내 사용자 고유 ID")
|
||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||
is_active: bool = Field(..., description="활성화 상태")
|
||||
created_at: datetime = Field(..., description="연동 일시")
|
||||
updated_at: datetime = Field(..., description="수정 일시")
|
||||
|
||||
model_config = {
|
||||
"from_attributes": True,
|
||||
"populate_by_name": True,
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"account_id": 1,
|
||||
"platform": "instagram",
|
||||
"platform_user_id": "17841400000000000",
|
||||
"platform_username": "my_instagram_account",
|
||||
"platform_data": {
|
||||
"business_account_id": "17841400000000000"
|
||||
},
|
||||
"scope": "instagram_basic,instagram_content_publish",
|
||||
"token_expires_at": "2026-03-15T10:30:00",
|
||||
"is_active": True,
|
||||
"created_at": "2026-01-15T10:30:00",
|
||||
"updated_at": "2026-01-15T10:30:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountListResponse(BaseModel):
|
||||
"""소셜 계정 목록 응답"""
|
||||
|
||||
items: list[SocialAccountResponse] = Field(..., description="소셜 계정 목록")
|
||||
total: int = Field(..., description="총 계정 수")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"items": [
|
||||
{
|
||||
"account_id": 1,
|
||||
"platform": "instagram",
|
||||
"platform_user_id": "17841400000000000",
|
||||
"platform_username": "my_instagram_account",
|
||||
"platform_data": None,
|
||||
"scope": "instagram_basic",
|
||||
"token_expires_at": "2026-03-15T10:30:00",
|
||||
"is_active": True,
|
||||
"created_at": "2026-01-15T10:30:00",
|
||||
"updated_at": "2026-01-15T10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountDeleteResponse(BaseModel):
|
||||
"""소셜 계정 삭제 응답"""
|
||||
|
||||
message: str = Field(..., description="결과 메시지")
|
||||
deleted_id: int = Field(..., description="삭제된 계정 ID")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"message": "소셜 계정이 삭제되었습니다.",
|
||||
"deleted_id": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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):
|
||||
"""토큰 갱신 요청"""
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.utils.timezone import now
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -18,72 +16,19 @@ from config import prj_settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 인증 예외 클래스 정의
|
||||
# =============================================================================
|
||||
class AuthException(HTTPException):
|
||||
"""인증 관련 기본 예외"""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str):
|
||||
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
class TokenExpiredError(AuthException):
|
||||
"""토큰 만료"""
|
||||
|
||||
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_EXPIRED", message)
|
||||
|
||||
|
||||
class InvalidTokenError(AuthException):
|
||||
"""유효하지 않은 토큰"""
|
||||
|
||||
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "INVALID_TOKEN", message)
|
||||
|
||||
|
||||
class TokenRevokedError(AuthException):
|
||||
"""취소된 토큰"""
|
||||
|
||||
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_REVOKED", message)
|
||||
|
||||
|
||||
class MissingTokenError(AuthException):
|
||||
"""토큰 누락"""
|
||||
|
||||
def __init__(self, message: str = "인증 토큰이 필요합니다."):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, "MISSING_TOKEN", message)
|
||||
|
||||
|
||||
class UserNotFoundError(AuthException):
|
||||
"""사용자 없음"""
|
||||
|
||||
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
|
||||
super().__init__(status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", message)
|
||||
|
||||
|
||||
class UserInactiveError(AuthException):
|
||||
"""비활성화된 계정"""
|
||||
|
||||
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
|
||||
super().__init__(status.HTTP_403_FORBIDDEN, "USER_INACTIVE", message)
|
||||
|
||||
|
||||
class AdminRequiredError(AuthException):
|
||||
"""관리자 권한 필요"""
|
||||
|
||||
def __init__(self, message: str = "관리자 권한이 필요합니다."):
|
||||
super().__init__(status.HTTP_403_FORBIDDEN, "ADMIN_REQUIRED", message)
|
||||
|
||||
|
||||
from app.user.exceptions import (
|
||||
InvalidTokenError,
|
||||
TokenExpiredError,
|
||||
TokenRevokedError,
|
||||
UserInactiveError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from app.user.models import RefreshToken, User
|
||||
from app.utils.common import generate_uuid
|
||||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoUserInfo,
|
||||
LoginResponse,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.user.services.jwt import (
|
||||
create_access_token,
|
||||
|
|
@ -168,7 +113,7 @@ class AuthService:
|
|||
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
|
||||
|
||||
# 7. 마지막 로그인 시간 업데이트
|
||||
user.last_login_at = now().replace(tzinfo=None)
|
||||
user.last_login_at = datetime.now()
|
||||
await session.commit()
|
||||
|
||||
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
|
||||
|
|
@ -188,129 +133,59 @@ class AuthService:
|
|||
self,
|
||||
refresh_token: str,
|
||||
session: AsyncSession,
|
||||
) -> TokenResponse:
|
||||
) -> AccessTokenResponse:
|
||||
"""
|
||||
리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation)
|
||||
|
||||
기존 리프레시 토큰을 폐기하고, 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다.
|
||||
사용자가 서비스를 지속 사용하는 한 세션이 자동 유지됩니다.
|
||||
리프레시 토큰으로 액세스 토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
TokenResponse: 새 액세스 토큰 + 새 리프레시 토큰
|
||||
AccessTokenResponse: 새 액세스 토큰
|
||||
|
||||
Raises:
|
||||
InvalidTokenError: 토큰이 유효하지 않은 경우
|
||||
TokenExpiredError: 토큰이 만료된 경우
|
||||
TokenRevokedError: 토큰이 폐기된 경우
|
||||
"""
|
||||
logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}")
|
||||
|
||||
# 1. 토큰 디코딩 및 검증
|
||||
payload = decode_token(refresh_token)
|
||||
if payload is None:
|
||||
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
|
||||
raise InvalidTokenError()
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, "
|
||||
f"sub: {payload.get('sub')}"
|
||||
)
|
||||
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, "
|
||||
f"exp: {payload.get('exp')}"
|
||||
)
|
||||
|
||||
# 2. DB에서 리프레시 토큰 조회
|
||||
token_hash = get_token_hash(refresh_token)
|
||||
db_token = await self._get_refresh_token_by_hash(token_hash, session)
|
||||
|
||||
if db_token is None:
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, "
|
||||
f"token_hash: {token_hash[:16]}..."
|
||||
)
|
||||
raise InvalidTokenError()
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., "
|
||||
f"user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, "
|
||||
f"expires_at: {db_token.expires_at}"
|
||||
)
|
||||
|
||||
# 3. 토큰 상태 확인
|
||||
if db_token.is_revoked:
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), "
|
||||
f"token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, "
|
||||
f"revoked_at: {db_token.revoked_at}"
|
||||
)
|
||||
raise TokenRevokedError()
|
||||
|
||||
# 4. 만료 확인
|
||||
if db_token.expires_at < now().replace(tzinfo=None):
|
||||
logger.info(
|
||||
f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, "
|
||||
f"user_uuid: {db_token.user_uuid}"
|
||||
)
|
||||
if db_token.expires_at < datetime.now():
|
||||
raise TokenExpiredError()
|
||||
|
||||
# 5. 사용자 확인
|
||||
# 4. 사용자 확인
|
||||
user_uuid = payload.get("sub")
|
||||
user = await self._get_user_by_uuid(user_uuid, session)
|
||||
|
||||
if user is None:
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}"
|
||||
)
|
||||
raise UserNotFoundError()
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, "
|
||||
f"user_id: {user.id}"
|
||||
)
|
||||
raise UserInactiveError()
|
||||
|
||||
# 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음)
|
||||
db_token.is_revoked = True
|
||||
db_token.revoked_at = now().replace(tzinfo=None)
|
||||
logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...")
|
||||
|
||||
# 7. 새 토큰 발급
|
||||
# 5. 새 액세스 토큰 발급
|
||||
new_access_token = create_access_token(user.user_uuid)
|
||||
new_refresh_token = create_refresh_token(user.user_uuid)
|
||||
logger.debug(
|
||||
f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, "
|
||||
f"new_refresh: ...{new_refresh_token[-20:]}"
|
||||
)
|
||||
|
||||
# 8. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행)
|
||||
await self._save_refresh_token(
|
||||
user_id=user.id,
|
||||
user_uuid=user.user_uuid,
|
||||
token=new_refresh_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 폐기 + 저장을 하나의 트랜잭션으로 커밋
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, "
|
||||
f"user_id: {user.id}, old_hash: {token_hash[:16]}..., "
|
||||
f"new_refresh: ...{new_refresh_token[-20:]}"
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
return AccessTokenResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="Bearer",
|
||||
expires_in=get_access_token_expire_seconds(),
|
||||
)
|
||||
|
|
@ -330,12 +205,7 @@ class AuthService:
|
|||
session: DB 세션
|
||||
"""
|
||||
token_hash = get_token_hash(refresh_token)
|
||||
logger.info(
|
||||
f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., "
|
||||
f"token: ...{refresh_token[-20:]}"
|
||||
)
|
||||
await self._revoke_refresh_token_by_hash(token_hash, session)
|
||||
logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}")
|
||||
|
||||
async def logout_all(
|
||||
self,
|
||||
|
|
@ -349,9 +219,7 @@ class AuthService:
|
|||
user_id: 사용자 ID
|
||||
session: DB 세션
|
||||
"""
|
||||
logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}")
|
||||
await self._revoke_all_user_tokens(user_id, session)
|
||||
logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}")
|
||||
|
||||
async def _get_or_create_user(
|
||||
self,
|
||||
|
|
@ -481,11 +349,6 @@ class AuthService:
|
|||
)
|
||||
session.add(refresh_token)
|
||||
await session.flush()
|
||||
|
||||
logger.debug(
|
||||
f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, "
|
||||
f"token_hash: {token_hash[:16]}..., expires_at: {expires_at}"
|
||||
)
|
||||
return refresh_token
|
||||
|
||||
async def _get_refresh_token_by_hash(
|
||||
|
|
@ -565,7 +428,7 @@ class AuthService:
|
|||
.where(RefreshToken.token_hash == token_hash)
|
||||
.values(
|
||||
is_revoked=True,
|
||||
revoked_at=now().replace(tzinfo=None),
|
||||
revoked_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
|
@ -590,7 +453,7 @@ class AuthService:
|
|||
)
|
||||
.values(
|
||||
is_revoked=True,
|
||||
revoked_at=now().replace(tzinfo=None),
|
||||
revoked_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
|
|
|||
|
|
@ -5,18 +5,13 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니
|
|||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
|
||||
|
||||
from app.utils.timezone import now
|
||||
from config import jwt_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_access_token(user_uuid: str) -> str:
|
||||
"""
|
||||
|
|
@ -28,7 +23,7 @@ def create_access_token(user_uuid: str) -> str:
|
|||
Returns:
|
||||
JWT 액세스 토큰 문자열
|
||||
"""
|
||||
expire = now() + timedelta(
|
||||
expire = datetime.now() + timedelta(
|
||||
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode = {
|
||||
|
|
@ -36,16 +31,11 @@ def create_access_token(user_uuid: str) -> str:
|
|||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
token = jwt.encode(
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
jwt_settings.JWT_SECRET,
|
||||
algorithm=jwt_settings.JWT_ALGORITHM,
|
||||
)
|
||||
logger.debug(
|
||||
f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, "
|
||||
f"expires: {expire}, token: ...{token[-20:]}"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def create_refresh_token(user_uuid: str) -> str:
|
||||
|
|
@ -58,7 +48,7 @@ def create_refresh_token(user_uuid: str) -> str:
|
|||
Returns:
|
||||
JWT 리프레시 토큰 문자열
|
||||
"""
|
||||
expire = now() + timedelta(
|
||||
expire = datetime.now() + timedelta(
|
||||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
to_encode = {
|
||||
|
|
@ -66,16 +56,11 @@ def create_refresh_token(user_uuid: str) -> str:
|
|||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
token = jwt.encode(
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
jwt_settings.JWT_SECRET,
|
||||
algorithm=jwt_settings.JWT_ALGORITHM,
|
||||
)
|
||||
logger.debug(
|
||||
f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, "
|
||||
f"expires: {expire}, token: ...{token[-20:]}"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
|
|
@ -94,25 +79,8 @@ def decode_token(token: str) -> Optional[dict]:
|
|||
jwt_settings.JWT_SECRET,
|
||||
algorithms=[jwt_settings.JWT_ALGORITHM],
|
||||
)
|
||||
logger.debug(
|
||||
f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, "
|
||||
f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, "
|
||||
f"token: ...{token[-20:]}"
|
||||
)
|
||||
return payload
|
||||
except ExpiredSignatureError:
|
||||
logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}")
|
||||
return None
|
||||
except JWTClaimsError as e:
|
||||
logger.warning(
|
||||
f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}"
|
||||
)
|
||||
return None
|
||||
except JWTError as e:
|
||||
logger.warning(
|
||||
f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, "
|
||||
f"token: ...{token[-20:]}"
|
||||
)
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -138,7 +106,7 @@ def get_refresh_token_expires_at() -> datetime:
|
|||
Returns:
|
||||
리프레시 토큰 만료 datetime (로컬 시간)
|
||||
"""
|
||||
return now().replace(tzinfo=None) + timedelta(
|
||||
return datetime.now() + timedelta(
|
||||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,39 +7,15 @@
|
|||
import logging
|
||||
|
||||
import aiohttp
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from config import kakao_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError
|
||||
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 카카오 OAuth 예외 클래스 정의
|
||||
# =============================================================================
|
||||
class KakaoException(HTTPException):
|
||||
"""카카오 관련 기본 예외"""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str):
|
||||
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
class KakaoAuthFailedError(KakaoException):
|
||||
"""카카오 인증 실패"""
|
||||
|
||||
def __init__(self, message: str = "카카오 인증에 실패했습니다."):
|
||||
super().__init__(status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED", message)
|
||||
|
||||
|
||||
class KakaoAPIError(KakaoException):
|
||||
"""카카오 API 호출 오류"""
|
||||
|
||||
def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."):
|
||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "KAKAO_API_ERROR", message)
|
||||
|
||||
|
||||
class KakaoOAuthClient:
|
||||
"""
|
||||
카카오 OAuth API 클라이언트
|
||||
|
|
|
|||
|
|
@ -1,259 +0,0 @@
|
|||
"""
|
||||
SocialAccount 서비스 레이어
|
||||
|
||||
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.user.models import Platform, SocialAccount, User
|
||||
from app.user.schemas.social_account_schema import (
|
||||
SocialAccountCreateRequest,
|
||||
SocialAccountUpdateRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SocialAccountService:
|
||||
"""소셜 계정 서비스"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_list(self, user: User) -> list[SocialAccount]:
|
||||
"""
|
||||
사용자의 소셜 계정 목록 조회
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
|
||||
Returns:
|
||||
list[SocialAccount]: 소셜 계정 목록
|
||||
"""
|
||||
logger.debug(f"[SocialAccountService.get_list] START - user_uuid: {user.user_uuid}")
|
||||
|
||||
result = await self.session.execute(
|
||||
select(SocialAccount).where(
|
||||
and_(
|
||||
SocialAccount.user_uuid == user.user_uuid,
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
).order_by(SocialAccount.created_at.desc())
|
||||
)
|
||||
accounts = list(result.scalars().all())
|
||||
|
||||
logger.debug(f"[SocialAccountService.get_list] SUCCESS - count: {len(accounts)}")
|
||||
return accounts
|
||||
|
||||
async def get_by_id(self, user: User, account_id: int) -> Optional[SocialAccount]:
|
||||
"""
|
||||
ID로 소셜 계정 조회
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
account_id: 소셜 계정 ID
|
||||
|
||||
Returns:
|
||||
SocialAccount | None: 소셜 계정 또는 None
|
||||
"""
|
||||
logger.debug(f"[SocialAccountService.get_by_id] START - user_uuid: {user.user_uuid}, account_id: {account_id}")
|
||||
|
||||
result = await self.session.execute(
|
||||
select(SocialAccount).where(
|
||||
and_(
|
||||
SocialAccount.id == account_id,
|
||||
SocialAccount.user_uuid == user.user_uuid,
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if account:
|
||||
logger.debug(f"[SocialAccountService.get_by_id] SUCCESS - platform: {account.platform}")
|
||||
else:
|
||||
logger.debug(f"[SocialAccountService.get_by_id] NOT_FOUND - account_id: {account_id}")
|
||||
|
||||
return account
|
||||
|
||||
async def get_by_platform(
|
||||
self,
|
||||
user: User,
|
||||
platform: Platform,
|
||||
platform_user_id: Optional[str] = None,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
플랫폼별 소셜 계정 조회
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
platform: 플랫폼
|
||||
platform_user_id: 플랫폼 사용자 ID (선택)
|
||||
|
||||
Returns:
|
||||
SocialAccount | None: 소셜 계정 또는 None
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SocialAccountService.get_by_platform] START - user_uuid: {user.user_uuid}, "
|
||||
f"platform: {platform}, platform_user_id: {platform_user_id}"
|
||||
)
|
||||
|
||||
conditions = [
|
||||
SocialAccount.user_uuid == user.user_uuid,
|
||||
SocialAccount.platform == platform,
|
||||
SocialAccount.is_deleted == False, # noqa: E712
|
||||
]
|
||||
|
||||
if platform_user_id:
|
||||
conditions.append(SocialAccount.platform_user_id == platform_user_id)
|
||||
|
||||
result = await self.session.execute(
|
||||
select(SocialAccount).where(and_(*conditions))
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if account:
|
||||
logger.debug(f"[SocialAccountService.get_by_platform] SUCCESS - id: {account.id}")
|
||||
else:
|
||||
logger.debug(f"[SocialAccountService.get_by_platform] NOT_FOUND")
|
||||
|
||||
return account
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user: User,
|
||||
data: SocialAccountCreateRequest,
|
||||
) -> SocialAccount:
|
||||
"""
|
||||
소셜 계정 생성
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
data: 생성 요청 데이터
|
||||
|
||||
Returns:
|
||||
SocialAccount: 생성된 소셜 계정
|
||||
|
||||
Raises:
|
||||
ValueError: 이미 연동된 계정이 존재하는 경우
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SocialAccountService.create] START - user_uuid: {user.user_uuid}, "
|
||||
f"platform: {data.platform}, platform_user_id: {data.platform_user_id}"
|
||||
)
|
||||
|
||||
# 중복 확인
|
||||
existing = await self.get_by_platform(user, data.platform, data.platform_user_id)
|
||||
if existing:
|
||||
logger.warning(
|
||||
f"[SocialAccountService.create] DUPLICATE - "
|
||||
f"platform: {data.platform}, platform_user_id: {data.platform_user_id}"
|
||||
)
|
||||
raise ValueError(f"이미 연동된 {data.platform.value} 계정입니다.")
|
||||
|
||||
account = SocialAccount(
|
||||
user_uuid=user.user_uuid,
|
||||
platform=data.platform,
|
||||
access_token=data.access_token,
|
||||
refresh_token=data.refresh_token,
|
||||
token_expires_at=data.token_expires_at,
|
||||
scope=data.scope,
|
||||
platform_user_id=data.platform_user_id,
|
||||
platform_username=data.platform_username,
|
||||
platform_data=data.platform_data,
|
||||
is_active=True,
|
||||
is_deleted=False,
|
||||
)
|
||||
|
||||
self.session.add(account)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(account)
|
||||
|
||||
logger.info(
|
||||
f"[SocialAccountService.create] SUCCESS - id: {account.id}, "
|
||||
f"platform: {account.platform}, platform_username: {account.platform_username}"
|
||||
)
|
||||
return account
|
||||
|
||||
async def update(
|
||||
self,
|
||||
user: User,
|
||||
account_id: int,
|
||||
data: SocialAccountUpdateRequest,
|
||||
) -> Optional[SocialAccount]:
|
||||
"""
|
||||
소셜 계정 수정
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
account_id: 소셜 계정 ID
|
||||
data: 수정 요청 데이터
|
||||
|
||||
Returns:
|
||||
SocialAccount | None: 수정된 소셜 계정 또는 None
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SocialAccountService.update] START - user_uuid: {user.user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
|
||||
account = await self.get_by_id(user, account_id)
|
||||
if not account:
|
||||
logger.warning(f"[SocialAccountService.update] NOT_FOUND - account_id: {account_id}")
|
||||
return None
|
||||
|
||||
# 변경된 필드만 업데이트
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(account, field, value)
|
||||
|
||||
await self.session.commit()
|
||||
await self.session.refresh(account)
|
||||
|
||||
logger.info(
|
||||
f"[SocialAccountService.update] SUCCESS - id: {account.id}, "
|
||||
f"updated_fields: {list(update_data.keys())}"
|
||||
)
|
||||
return account
|
||||
|
||||
async def delete(self, user: User, account_id: int) -> Optional[int]:
|
||||
"""
|
||||
소셜 계정 소프트 삭제
|
||||
|
||||
Args:
|
||||
user: 현재 로그인한 사용자
|
||||
account_id: 소셜 계정 ID
|
||||
|
||||
Returns:
|
||||
int | None: 삭제된 계정 ID 또는 None
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SocialAccountService.delete] START - user_uuid: {user.user_uuid}, account_id: {account_id}"
|
||||
)
|
||||
|
||||
account = await self.get_by_id(user, account_id)
|
||||
if not account:
|
||||
logger.warning(f"[SocialAccountService.delete] NOT_FOUND - account_id: {account_id}")
|
||||
return None
|
||||
|
||||
account.is_deleted = True
|
||||
account.is_active = False
|
||||
await self.session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[SocialAccountService.delete] SUCCESS - id: {account_id}, platform: {account.platform}"
|
||||
)
|
||||
return account_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 의존성 주입용 함수
|
||||
# =============================================================================
|
||||
async def get_social_account_service(session: AsyncSession) -> SocialAccountService:
|
||||
"""SocialAccountService 인스턴스 반환"""
|
||||
return SocialAccountService(session)
|
||||
|
|
@ -1,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
|
||||
|
|
@ -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
|
||||
|
|
@ -31,13 +31,11 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
|||
|
||||
import copy
|
||||
import time
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
import traceback
|
||||
|
||||
import httpx
|
||||
|
||||
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
|
||||
|
||||
# 로거 설정
|
||||
|
|
@ -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 = {
|
||||
"type": "composition",
|
||||
"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:
|
||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||
global _shared_client
|
||||
|
|
@ -288,10 +190,23 @@ class CreatomateService:
|
|||
|
||||
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__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
orientation: OrientationType = "vertical"
|
||||
orientation: OrientationType = "vertical",
|
||||
target_duration: float | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
|
|
@ -305,7 +220,14 @@ class CreatomateService:
|
|||
self.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 = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
|
|
@ -402,6 +324,14 @@ class CreatomateService:
|
|||
|
||||
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:
|
||||
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
||||
|
||||
|
|
@ -429,117 +359,27 @@ class CreatomateService:
|
|||
|
||||
return result
|
||||
|
||||
async def parse_template_name_tag(resource_name : str) -> list:
|
||||
tag_list = []
|
||||
tag_list = resource_name.split("_")
|
||||
|
||||
return tag_list
|
||||
|
||||
|
||||
def counting_component(
|
||||
async def template_connect_resource_blackbox(
|
||||
self,
|
||||
template : dict,
|
||||
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,
|
||||
template_id: str,
|
||||
image_url_list: list[str],
|
||||
lyric: str,
|
||||
music_url: str,
|
||||
address: str = None
|
||||
) -> 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 = {}
|
||||
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
|
|
@ -551,8 +391,40 @@ class CreatomateService:
|
|||
idx % len(image_url_list)
|
||||
]
|
||||
case "text":
|
||||
if "address_input" in template_component_name:
|
||||
modifications[template_component_name] = address
|
||||
modifications[template_component_name] = lyric_splited[
|
||||
idx % len(lyric_splited)
|
||||
]
|
||||
|
||||
modifications["audio-music"] = music_url
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -571,8 +443,7 @@ class CreatomateService:
|
|||
case "video":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "text":
|
||||
#element["source"] = modification[element["name"]]
|
||||
element["text"] = modification.get(element["name"], "")
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "composition":
|
||||
for minor in element["elements"]:
|
||||
recursive_modify(minor)
|
||||
|
|
@ -729,6 +600,14 @@ class CreatomateService:
|
|||
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:
|
||||
"""렌더링 작업의 상태를 조회합니다.
|
||||
|
||||
|
|
@ -752,58 +631,47 @@ class CreatomateService:
|
|||
response.raise_for_status()
|
||||
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:
|
||||
"""템플릿의 전체 장면 duration을 계산합니다."""
|
||||
total_template_duration = 0.0
|
||||
track_maximum_duration = {
|
||||
SCENE_TRACK : 0,
|
||||
SUBTITLE_TRACK : 0,
|
||||
KEYWORD_TRACK : 0
|
||||
}
|
||||
|
||||
for elem in template["source"]["elements"]:
|
||||
try:
|
||||
if elem["track"] not in track_maximum_duration:
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음
|
||||
track_maximum_duration[elem["track"]] += elem["duration"]
|
||||
|
||||
total_template_duration += elem["duration"]
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
if "transition" in animation and animation["transition"]:
|
||||
track_maximum_duration[elem["track"]] -= animation["duration"]
|
||||
else:
|
||||
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
|
||||
|
||||
if animation["transition"]:
|
||||
total_template_duration -= animation["duration"]
|
||||
except Exception as e:
|
||||
logger.debug(traceback.format_exc())
|
||||
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
|
||||
|
||||
total_template_duration = max(track_maximum_duration.values())
|
||||
|
||||
return total_template_duration
|
||||
|
||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||
# template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
||||
# target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
||||
target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
||||
template["duration"] = target_duration
|
||||
total_template_duration = self.calc_scene_duration(template)
|
||||
extend_rate = target_duration / total_template_duration
|
||||
new_template = copy.deepcopy(template)
|
||||
|
||||
for elem in new_template["source"]["elements"]:
|
||||
try:
|
||||
# if elem["type"] == "audio":
|
||||
# continue
|
||||
if elem["track"] == AUDIO_TRACK : # audio track은 패스
|
||||
if elem["type"] == "audio":
|
||||
continue
|
||||
|
||||
if "time" in elem:
|
||||
elem["time"] = elem["time"] * extend_rate
|
||||
if "duration" in elem:
|
||||
elem["duration"] = elem["duration"] * extend_rate
|
||||
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
|
|
@ -816,7 +684,7 @@ class CreatomateService:
|
|||
|
||||
return new_template
|
||||
|
||||
def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float, font_family: str = "Noto Sans") -> dict:
|
||||
def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float) -> dict:
|
||||
duration = end_sec - start_sec
|
||||
text_scene = copy.deepcopy(text_template)
|
||||
text_scene["name"] = f"Caption-{lyric_index}"
|
||||
|
|
@ -824,45 +692,13 @@ class CreatomateService:
|
|||
text_scene["time"] = start_sec
|
||||
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
|
||||
text_scene["elements"][0]["text"] = lyric_text
|
||||
text_scene["elements"][0]["font_family"] = font_family
|
||||
return text_scene
|
||||
|
||||
def auto_lyric(self, auto_text_template : dict):
|
||||
text_scene = copy.deepcopy(auto_text_template)
|
||||
return text_scene
|
||||
|
||||
def get_text_template(self):
|
||||
match self.orientation:
|
||||
case "vertical":
|
||||
return text_template_v_3
|
||||
return text_template_v_2
|
||||
case "horizontal":
|
||||
return text_template_h_1
|
||||
|
||||
def get_auto_text_template(self):
|
||||
match self.orientation:
|
||||
case "vertical":
|
||||
return autotext_template_v_1
|
||||
case "horizontal":
|
||||
return autotext_template_h_1
|
||||
|
||||
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
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
"""
|
||||
Instagram Graph API Client
|
||||
|
||||
Instagram Graph API를 사용한 비디오/릴스 게시를 위한 비동기 클라이언트입니다.
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with InstagramClient(access_token="YOUR_TOKEN") as client:
|
||||
media = await client.publish_video(
|
||||
video_url="https://example.com/video.mp4",
|
||||
caption="Hello Instagram!"
|
||||
)
|
||||
print(f"게시 완료: {media.permalink}")
|
||||
```
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.sns.schemas.sns_schema import ErrorResponse, Media, MediaContainer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Error State & Parser
|
||||
# ============================================================
|
||||
|
||||
|
||||
class ErrorState(str, Enum):
|
||||
"""Instagram API 에러 상태"""
|
||||
|
||||
RATE_LIMIT = "rate_limit"
|
||||
AUTH_ERROR = "auth_error"
|
||||
CONTAINER_TIMEOUT = "container_timeout"
|
||||
CONTAINER_ERROR = "container_error"
|
||||
API_ERROR = "api_error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def parse_instagram_error(e: Exception) -> tuple[ErrorState, str, dict]:
|
||||
"""
|
||||
Instagram 예외를 파싱하여 상태, 메시지, 추가 정보를 반환
|
||||
|
||||
Args:
|
||||
e: 발생한 예외
|
||||
|
||||
Returns:
|
||||
tuple: (error_state, message, extra_info)
|
||||
|
||||
Example:
|
||||
>>> error_state, message, extra_info = parse_instagram_error(e)
|
||||
>>> if error_state == ErrorState.RATE_LIMIT:
|
||||
... retry_after = extra_info.get("retry_after", 60)
|
||||
"""
|
||||
error_str = str(e)
|
||||
extra_info = {}
|
||||
|
||||
# Rate Limit 에러
|
||||
if "[RateLimit]" in error_str:
|
||||
match = re.search(r"retry_after=(\d+)s", error_str)
|
||||
if match:
|
||||
extra_info["retry_after"] = int(match.group(1))
|
||||
return ErrorState.RATE_LIMIT, "API 호출 제한 초과", extra_info
|
||||
|
||||
# 인증 에러 (code=190)
|
||||
if "code=190" in error_str:
|
||||
return ErrorState.AUTH_ERROR, "인증 실패 (토큰 만료 또는 무효)", extra_info
|
||||
|
||||
# 컨테이너 타임아웃
|
||||
if "[ContainerTimeout]" in error_str:
|
||||
match = re.search(r"\((\d+)초 초과\)", error_str)
|
||||
if match:
|
||||
extra_info["timeout"] = int(match.group(1))
|
||||
return ErrorState.CONTAINER_TIMEOUT, "미디어 처리 시간 초과", extra_info
|
||||
|
||||
# 컨테이너 상태 에러
|
||||
if "[ContainerStatus]" in error_str:
|
||||
match = re.search(r"처리 실패: (\w+)", error_str)
|
||||
if match:
|
||||
extra_info["status"] = match.group(1)
|
||||
return ErrorState.CONTAINER_ERROR, "미디어 컨테이너 처리 실패", extra_info
|
||||
|
||||
# Instagram API 에러
|
||||
if "[InstagramAPI]" in error_str:
|
||||
match = re.search(r"code=(\d+)", error_str)
|
||||
if match:
|
||||
extra_info["code"] = int(match.group(1))
|
||||
return ErrorState.API_ERROR, "Instagram API 오류", extra_info
|
||||
|
||||
return ErrorState.UNKNOWN, str(e), extra_info
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Instagram Client
|
||||
# ============================================================
|
||||
|
||||
|
||||
class InstagramClient:
|
||||
"""
|
||||
Instagram Graph API 비동기 클라이언트 (비디오 업로드 전용)
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with InstagramClient(access_token="USER_TOKEN") as client:
|
||||
media = await client.publish_video(
|
||||
video_url="https://example.com/video.mp4",
|
||||
caption="My video!"
|
||||
)
|
||||
print(f"게시됨: {media.permalink}")
|
||||
```
|
||||
"""
|
||||
|
||||
DEFAULT_BASE_URL = "https://graph.instagram.com/v21.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
container_timeout: float = 300.0,
|
||||
container_poll_interval: float = 5.0,
|
||||
):
|
||||
"""
|
||||
클라이언트 초기화
|
||||
|
||||
Args:
|
||||
access_token: Instagram 액세스 토큰 (필수)
|
||||
base_url: API 기본 URL (기본값: https://graph.instagram.com/v21.0)
|
||||
timeout: HTTP 요청 타임아웃 (초)
|
||||
max_retries: 최대 재시도 횟수
|
||||
container_timeout: 컨테이너 처리 대기 타임아웃 (초)
|
||||
container_poll_interval: 컨테이너 상태 확인 간격 (초)
|
||||
"""
|
||||
if not access_token:
|
||||
raise ValueError("access_token은 필수입니다.")
|
||||
|
||||
self.access_token = access_token
|
||||
self.base_url = base_url or self.DEFAULT_BASE_URL
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.container_timeout = container_timeout
|
||||
self.container_poll_interval = container_poll_interval
|
||||
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._account_id: Optional[str] = None
|
||||
self._account_id_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
async def __aenter__(self) -> "InstagramClient":
|
||||
"""비동기 컨텍스트 매니저 진입"""
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
follow_redirects=True,
|
||||
)
|
||||
logger.debug("[InstagramClient] HTTP 클라이언트 초기화 완료")
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""비동기 컨텍스트 매니저 종료"""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.debug("[InstagramClient] HTTP 클라이언트 종료")
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
"""HTTP 클라이언트 반환"""
|
||||
if self._client is None:
|
||||
raise RuntimeError(
|
||||
"InstagramClient는 비동기 컨텍스트 매니저로 사용해야 합니다. "
|
||||
"예: async with InstagramClient(access_token=...) as client:"
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _build_url(self, endpoint: str) -> str:
|
||||
"""API URL 생성"""
|
||||
return f"{self.base_url}/{endpoint}"
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
data: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
공통 HTTP 요청 처리
|
||||
|
||||
- Rate Limit 시 지수 백오프 재시도
|
||||
- 에러 응답 시 InstagramAPIError 발생
|
||||
"""
|
||||
client = self._get_client()
|
||||
url = self._build_url(endpoint)
|
||||
params = params or {}
|
||||
params["access_token"] = self.access_token
|
||||
|
||||
retry_base_delay = 1.0
|
||||
last_exception: Optional[Exception] = None
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
logger.debug(
|
||||
f"[API] {method} {endpoint} (attempt {attempt + 1}/{self.max_retries + 1})"
|
||||
)
|
||||
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
params=params,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Rate Limit 체크 (429)
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get("Retry-After", 60))
|
||||
if attempt < self.max_retries:
|
||||
wait_time = max(retry_base_delay * (2**attempt), retry_after)
|
||||
logger.warning(f"Rate limit 초과. {wait_time}초 후 재시도...")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
raise Exception(
|
||||
f"[RateLimit] Rate limit 초과 (최대 재시도 횟수 도달) | retry_after={retry_after}s"
|
||||
)
|
||||
|
||||
# 서버 에러 재시도 (5xx)
|
||||
if response.status_code >= 500:
|
||||
if attempt < self.max_retries:
|
||||
wait_time = retry_base_delay * (2**attempt)
|
||||
logger.warning(f"서버 에러 {response.status_code}. {wait_time}초 후 재시도...")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
|
||||
# JSON 파싱
|
||||
response_data = response.json()
|
||||
|
||||
# API 에러 체크 (Instagram API는 200 응답에도 error 포함 가능)
|
||||
if "error" in response_data:
|
||||
error_response = ErrorResponse.model_validate(response_data)
|
||||
err = error_response.error
|
||||
logger.error(f"[API Error] code={err.code}, message={err.message}")
|
||||
error_msg = f"[InstagramAPI] {err.message} | code={err.code}"
|
||||
if err.error_subcode:
|
||||
error_msg += f" | subcode={err.error_subcode}"
|
||||
if err.fbtrace_id:
|
||||
error_msg += f" | fbtrace_id={err.fbtrace_id}"
|
||||
raise Exception(error_msg)
|
||||
|
||||
return response_data
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
last_exception = e
|
||||
if attempt < self.max_retries:
|
||||
wait_time = retry_base_delay * (2**attempt)
|
||||
logger.warning(f"HTTP 에러: {e}. {wait_time}초 후 재시도...")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
raise
|
||||
|
||||
raise last_exception or Exception("[InstagramAPI] 최대 재시도 횟수 초과")
|
||||
|
||||
async def _wait_for_container(
|
||||
self,
|
||||
container_id: str,
|
||||
timeout: Optional[float] = None,
|
||||
) -> MediaContainer:
|
||||
"""컨테이너 상태가 FINISHED가 될 때까지 대기"""
|
||||
timeout = timeout or self.container_timeout
|
||||
start_time = time.monotonic()
|
||||
|
||||
logger.debug(f"[Container] 대기 시작: {container_id}, timeout={timeout}s")
|
||||
|
||||
while True:
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= timeout:
|
||||
raise Exception(
|
||||
f"[ContainerTimeout] 컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}"
|
||||
)
|
||||
|
||||
response = await self._request(
|
||||
method="GET",
|
||||
endpoint=container_id,
|
||||
params={"fields": "status_code,status"},
|
||||
)
|
||||
|
||||
container = MediaContainer.model_validate(response)
|
||||
logger.debug(f"[Container] status={container.status_code}, elapsed={elapsed:.1f}s")
|
||||
|
||||
if container.is_finished:
|
||||
logger.info(f"[Container] 완료: {container_id}")
|
||||
return container
|
||||
|
||||
if container.is_error:
|
||||
raise Exception(f"[ContainerStatus] 컨테이너 처리 실패: {container.status}")
|
||||
|
||||
await asyncio.sleep(self.container_poll_interval)
|
||||
|
||||
async def get_account_id(self) -> str:
|
||||
"""계정 ID 조회 (접속 테스트용)"""
|
||||
if self._account_id:
|
||||
return self._account_id
|
||||
|
||||
async with self._account_id_lock:
|
||||
if self._account_id:
|
||||
return self._account_id
|
||||
|
||||
response = await self._request(
|
||||
method="GET",
|
||||
endpoint="me",
|
||||
params={"fields": "id"},
|
||||
)
|
||||
account_id: str = response["id"]
|
||||
self._account_id = account_id
|
||||
logger.debug(f"[Account] ID 조회 완료: {account_id}")
|
||||
return account_id
|
||||
|
||||
async def get_media(self, media_id: str) -> Media:
|
||||
"""
|
||||
미디어 상세 조회
|
||||
|
||||
Args:
|
||||
media_id: 미디어 ID
|
||||
|
||||
Returns:
|
||||
Media: 미디어 상세 정보
|
||||
"""
|
||||
logger.info(f"[get_media] media_id={media_id}")
|
||||
|
||||
response = await self._request(
|
||||
method="GET",
|
||||
endpoint=media_id,
|
||||
params={
|
||||
"fields": "id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count",
|
||||
},
|
||||
)
|
||||
|
||||
result = Media.model_validate(response)
|
||||
logger.info(f"[get_media] 완료: type={result.media_type}, permalink={result.permalink}")
|
||||
return result
|
||||
|
||||
async def publish_video(
|
||||
self,
|
||||
video_url: str,
|
||||
caption: Optional[str] = None,
|
||||
share_to_feed: bool = True,
|
||||
) -> Media:
|
||||
"""
|
||||
비디오/릴스 게시
|
||||
|
||||
Args:
|
||||
video_url: 공개 접근 가능한 비디오 URL (MP4 권장)
|
||||
caption: 게시물 캡션
|
||||
share_to_feed: 피드에 공유 여부
|
||||
|
||||
Returns:
|
||||
Media: 게시된 미디어 정보
|
||||
"""
|
||||
logger.info(f"[publish_video] 시작: {video_url[:50]}...")
|
||||
account_id = await self.get_account_id()
|
||||
|
||||
# Step 1: Container 생성
|
||||
container_params: dict[str, Any] = {
|
||||
"media_type": "REELS",
|
||||
"video_url": video_url,
|
||||
"share_to_feed": str(share_to_feed).lower(),
|
||||
}
|
||||
if caption:
|
||||
container_params["caption"] = caption
|
||||
|
||||
container_response = await self._request(
|
||||
method="POST",
|
||||
endpoint=f"{account_id}/media",
|
||||
params=container_params,
|
||||
)
|
||||
container_id = container_response["id"]
|
||||
logger.debug(f"[publish_video] Container 생성: {container_id}")
|
||||
|
||||
# Step 2: Container 상태 대기 (비디오는 더 오래 걸림)
|
||||
await self._wait_for_container(container_id, timeout=self.container_timeout * 2)
|
||||
|
||||
# Step 3: 게시
|
||||
publish_response = await self._request(
|
||||
method="POST",
|
||||
endpoint=f"{account_id}/media_publish",
|
||||
params={"creation_id": container_id},
|
||||
)
|
||||
media_id = publish_response["id"]
|
||||
|
||||
result = await self.get_media(media_id)
|
||||
logger.info(f"[publish_video] 완료: {result.permalink}")
|
||||
return result
|
||||
|
|
@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
|
|||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Literal
|
||||
|
||||
from app.utils.timezone import today_str
|
||||
from config import log_settings
|
||||
|
||||
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
|
||||
|
|
@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler:
|
|||
global _shared_file_handler
|
||||
|
||||
if _shared_file_handler is None:
|
||||
today = today_str()
|
||||
today = datetime.today().strftime("%Y-%m-%d")
|
||||
log_file = LOG_DIR / f"{today}_app.log"
|
||||
|
||||
_shared_file_handler = RotatingFileHandler(
|
||||
|
|
@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler:
|
|||
global _shared_error_handler
|
||||
|
||||
if _shared_error_handler is None:
|
||||
today = today_str()
|
||||
today = datetime.today().strftime("%Y-%m-%d")
|
||||
log_file = LOG_DIR / f"{today}_error.log"
|
||||
|
||||
_shared_error_handler = RotatingFileHandler(
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
from urllib import parse
|
||||
import time
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("pwscraper")
|
||||
|
||||
class NvMapPwScraper():
|
||||
# cls vars
|
||||
|
|
@ -15,8 +10,7 @@ class NvMapPwScraper():
|
|||
_context = None
|
||||
_win_width = 1280
|
||||
_win_height = 720
|
||||
_max_retry = 3
|
||||
_timeout = 60 # place id timeout threshold seconds
|
||||
_max_retry = 30 # place id timeout threshold seconds
|
||||
|
||||
# instance var
|
||||
page = None
|
||||
|
|
@ -96,54 +90,22 @@ patchedGetter.toString();''')
|
|||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||
|
||||
async def get_place_id_url(self, selected):
|
||||
count = 0
|
||||
get_place_id_url_start = time.perf_counter()
|
||||
while (count <= self._max_retry):
|
||||
|
||||
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||
encoded_query = parse.quote(f"{address} {title}")
|
||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||
|
||||
wait_first_start = time.perf_counter()
|
||||
|
||||
try:
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||
except:
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||
|
||||
|
||||
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
|
||||
|
||||
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
|
||||
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
|
||||
wait_forced_correct_start = time.perf_counter()
|
||||
|
||||
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||
try:
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||
except:
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||
|
||||
wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000
|
||||
logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
if "/place/" in 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):
|
||||
# raise Exception("Failed to identify place id. loading timeout")
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ class GraphQLException(Exception):
|
|||
"""GraphQL 요청 실패 시 발생하는 예외"""
|
||||
pass
|
||||
|
||||
class URLNotFoundException(Exception):
|
||||
"""Place ID 발견 불가능 시 발생하는 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class CrawlingTimeoutException(Exception):
|
||||
"""크롤링 타임아웃 시 발생하는 예외"""
|
||||
|
|
@ -34,7 +30,7 @@ class NvMapScraper:
|
|||
|
||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||
REQUEST_TIMEOUT = 120 # 초
|
||||
data_source_identifier = "nv"
|
||||
|
||||
OVERVIEW_QUERY: str = """
|
||||
query getAccommodation($id: String!, $deviceType: String) {
|
||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||
|
|
@ -90,20 +86,19 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
async with session.get(self.url) as response:
|
||||
self.url = str(response.url)
|
||||
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)
|
||||
if not match:
|
||||
raise URLNotFoundException("Failed to parse place ID from URL")
|
||||
raise GraphQLException("Failed to parse place ID from URL")
|
||||
return match[1]
|
||||
|
||||
async def scrap(self):
|
||||
try:
|
||||
place_id = await self.parse_url()
|
||||
data = await self._call_get_accommodation(place_id)
|
||||
self.rawdata = data
|
||||
fac_data = await self._get_facility_string(place_id)
|
||||
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
||||
self.place_id = self.data_source_identifier + place_id
|
||||
self.rawdata["facilities"] = fac_data
|
||||
self.image_link_list = [
|
||||
nv_image["origin"]
|
||||
|
|
@ -113,6 +108,11 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
self.facility_info = fac_data
|
||||
self.scrap_type = "GraphQL"
|
||||
|
||||
except GraphQLException:
|
||||
logger.debug("GraphQL failed, fallback to Playwright")
|
||||
self.scrap_type = "Playwright"
|
||||
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||
|
||||
return
|
||||
|
||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,89 +1,57 @@
|
|||
import gspread
|
||||
import os, json
|
||||
from pydantic import BaseModel
|
||||
from google.oauth2.service_account import Credentials
|
||||
from config import prompt_settings
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.prompts.schemas import *
|
||||
from functools import lru_cache
|
||||
|
||||
logger = get_logger("prompt")
|
||||
|
||||
_SCOPES = [
|
||||
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
||||
"https://www.googleapis.com/auth/drive.readonly"
|
||||
]
|
||||
|
||||
class Prompt():
|
||||
sheet_name: str
|
||||
prompt_template: str
|
||||
prompt_model: str
|
||||
prompt_template_path : str #프롬프트 경로
|
||||
prompt_template : str # fstring 포맷
|
||||
prompt_model : str
|
||||
|
||||
prompt_input_class = BaseModel
|
||||
prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
|
||||
prompt_output_class = BaseModel
|
||||
|
||||
def __init__(self, sheet_name, prompt_input_class, prompt_output_class):
|
||||
self.sheet_name = sheet_name
|
||||
def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
|
||||
self.prompt_template_path = prompt_template_path
|
||||
self.prompt_input_class = prompt_input_class
|
||||
self.prompt_output_class = prompt_output_class
|
||||
self.prompt_template, self.prompt_model = self._read_from_sheets()
|
||||
|
||||
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
|
||||
self.prompt_template = self.read_prompt()
|
||||
self.prompt_model = prompt_model
|
||||
|
||||
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)
|
||||
build_template = self.prompt_template
|
||||
build_template = build_template.format(**verified_input.model_dump())
|
||||
if not silent:
|
||||
logger.debug(f"build_template: {build_template}")
|
||||
logger.debug(f"input_data: {input_data}")
|
||||
return build_template
|
||||
|
||||
marketing_prompt = Prompt(
|
||||
sheet_name="marketing",
|
||||
prompt_input_class=MarketingPromptInput,
|
||||
prompt_output_class=MarketingPromptOutput,
|
||||
prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
|
||||
prompt_input_class = MarketingPromptInput,
|
||||
prompt_output_class = MarketingPromptOutput,
|
||||
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
|
||||
)
|
||||
|
||||
lyric_prompt = Prompt(
|
||||
sheet_name="lyric",
|
||||
prompt_input_class=LyricPromptInput,
|
||||
prompt_output_class=LyricPromptOutput,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
|
||||
prompt_input_class = LyricPromptInput,
|
||||
prompt_output_class = LyricPromptOutput,
|
||||
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
|
||||
)
|
||||
|
||||
yt_upload_prompt = Prompt(
|
||||
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():
|
||||
marketing_prompt._reload_prompt()
|
||||
lyric_prompt._reload_prompt()
|
||||
yt_upload_prompt._reload_prompt()
|
||||
image_autotag_prompt._reload_prompt()
|
||||
|
|
@ -1,5 +1,2 @@
|
|||
from .lyric import LyricPromptInput, LyricPromptOutput
|
||||
from .marketing import MarketingPromptInput, MarketingPromptOutput
|
||||
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
|
||||
from .image import *
|
||||
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput
|
||||
|
|
|
|||
|
|
@ -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="이미지의 내러티브 상 점수")
|
||||
|
||||
|
|
@ -7,11 +7,13 @@ class MarketingPromptInput(BaseModel):
|
|||
region : str = Field(..., description = "마케팅 대상 지역")
|
||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
|
||||
|
||||
# Output 정의
|
||||
class BrandIdentity(BaseModel):
|
||||
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)
|
||||
|
||||
|
||||
class MarketPositioning(BaseModel):
|
||||
category_definition: str = Field(..., description="마케팅 카테고리")
|
||||
core_value: str = Field(..., description="마케팅 포지션 핵심 가치")
|
||||
|
|
@ -20,17 +22,18 @@ class AgeRange(BaseModel):
|
|||
min_age : int = Field(..., ge=0, le=100)
|
||||
max_age : int = Field(..., ge=0, le=100)
|
||||
|
||||
|
||||
class TargetPersona(BaseModel):
|
||||
persona: str = Field(..., description="타겟 페르소나 이름/설명")
|
||||
age: AgeRange = Field(..., description="타겟 페르소나 나이대")
|
||||
favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
|
||||
decision_trigger: str = Field(..., description="구매 결정 트리거")
|
||||
|
||||
|
||||
class SellingPoint(BaseModel):
|
||||
english_category: str = Field(..., description="셀링포인트 카테고리(영문)")
|
||||
korean_category: str = Field(..., description="셀링포인트 카테고리(한글)")
|
||||
category: 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):
|
||||
brand_identity: BrandIdentity = Field(..., description="브랜드 아이덴티티")
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue