diff --git a/README.md b/README.md index de19512..39d1a70 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,9 @@ uv sync # 이미 venv를 만든 경우 (기존 가상환경 활성화 필요) uv sync --active + +playwright install +playwright install-deps ``` ### 서버 실행 diff --git a/app/admin_manager.py b/app/admin_manager.py index 35cd37a..19c5628 100644 --- a/app/admin_manager.py +++ b/app/admin_manager.py @@ -5,6 +5,8 @@ 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 @@ -35,4 +37,12 @@ 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 diff --git a/app/database/session.py b/app/database/session.py index e3e54dd..d60db3c 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -77,6 +77,7 @@ async def create_db_tables(): 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 # 생성할 테이블 목록 tables_to_create = [ @@ -89,6 +90,7 @@ async def create_db_tables(): Song.__table__, SongTimestamp.__table__, Video.__table__, + SNSUploadTask.__table__, ] logger.info("Creating database tables...") diff --git a/app/sns/__init__.py b/app/sns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/api/__init__.py b/app/sns/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/api/routers/__init__.py b/app/sns/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/api/routers/v1/__init__.py b/app/sns/api/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/api/routers/v1/sns.py b/app/sns/api/routers/v1/sns.py new file mode 100644 index 0000000..e282b79 --- /dev/null +++ b/app/sns/api/routers/v1/sns.py @@ -0,0 +1,227 @@ +""" +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 + + +# ============================================================================= +# 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) +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__) + +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 ) +- 사용자의 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}") diff --git a/app/sns/api/sns_admin.py b/app/sns/api/sns_admin.py new file mode 100644 index 0000000..1c7cb23 --- /dev/null +++ b/app/sns/api/sns_admin.py @@ -0,0 +1,72 @@ +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": "생성일시", + } diff --git a/app/sns/dependency.py b/app/sns/dependency.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/models.py b/app/sns/models.py new file mode 100644 index 0000000..dcb2d04 --- /dev/null +++ b/app/sns/models.py @@ -0,0 +1,183 @@ +""" +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"" + ) diff --git a/app/sns/schemas/__init__.py b/app/sns/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/schemas/sns_schema.py b/app/sns/schemas/sns_schema.py new file mode 100644 index 0000000..51fc960 --- /dev/null +++ b/app/sns/schemas/sns_schema.py @@ -0,0 +1,148 @@ +""" +SNS API Schemas + +Instagram 업로드 관련 Pydantic 스키마를 정의합니다. +""" +from datetime import datetime +from typing import Any, 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 MediaList(BaseModel): + """미디어 목록 응답""" + + data: list[Media] = Field(default_factory=list) + paging: Optional[dict[str, Any]] = None + + @property + def next_cursor(self) -> Optional[str]: + """다음 페이지 커서""" + if self.paging and "cursors" in self.paging: + return self.paging["cursors"].get("after") + return 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 diff --git a/app/sns/services/__init__.py b/app/sns/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/services/sns.py b/app/sns/services/sns.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/tests/__init__.py b/app/sns/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/tests/conftest.py b/app/sns/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/tests/sns/__init__.py b/app/sns/tests/sns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/tests/sns/conftest.py b/app/sns/tests/sns/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/tests/sns/test_db.py b/app/sns/tests/sns/test_db.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/tests/test_db.py b/app/sns/tests/test_db.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/worker/__init__.py b/app/sns/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sns/worker/sns_task.py b/app/sns/worker/sns_task.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 3f2a07b..5b8330a 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -415,13 +415,6 @@ 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") @@ -435,12 +428,11 @@ 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}, store_name: {store_name}" + f"[get_song_status] Background task scheduled - song_id: {suno_task_id}" ) suno_audio_id = first_clip.get("id") diff --git a/app/song/worker/song_task.py b/app/song/worker/song_task.py index 617ea4d..64c6e08 100644 --- a/app/song/worker/song_task.py +++ b/app/song/worker/song_task.py @@ -4,8 +4,6 @@ Song Background Tasks 노래 생성 관련 백그라운드 태스크를 정의합니다. """ -import traceback -from datetime import date from pathlib import Path import aiofiles @@ -15,10 +13,8 @@ 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") @@ -118,87 +114,23 @@ 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}, store_name: {store_name}, duration: {duration}") + logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, duration: {duration}") temp_file_path: Path | None = None task_id: str | None = None @@ -220,12 +152,8 @@ 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}") - # 파일명에 사용할 수 없는 문자 제거 - 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" + # suno_task_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요) + file_name = f"{suno_task_id}.mp3" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id diff --git a/app/user/api/routers/v1/social_account.py b/app/user/api/routers/v1/social_account.py new file mode 100644 index 0000000..d508440 --- /dev/null +++ b/app/user/api/routers/v1/social_account.py @@ -0,0 +1,307 @@ +""" +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="소셜 계정 삭제 중 오류가 발생했습니다.", + ) diff --git a/app/user/api/routers/v1/user.py b/app/user/api/routers/v1/user.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py index 7f70ecb..af39add 100644 --- a/app/user/api/user_admin.py +++ b/app/user/api/user_admin.py @@ -160,16 +160,17 @@ class SocialAccountAdmin(ModelView, model=SocialAccount): column_list = [ "id", - "user_id", + "user_uuid", "platform", "platform_username", "is_active", - "connected_at", + "is_deleted", + "created_at", ] column_details_list = [ "id", - "user_id", + "user_uuid", "platform", "platform_user_id", "platform_username", @@ -177,32 +178,34 @@ class SocialAccountAdmin(ModelView, model=SocialAccount): "scope", "token_expires_at", "is_active", - "connected_at", + "is_deleted", + "created_at", "updated_at", ] - form_excluded_columns = ["connected_at", "updated_at", "user"] + form_excluded_columns = ["created_at", "updated_at", "user"] column_searchable_list = [ - SocialAccount.user_id, + SocialAccount.user_uuid, SocialAccount.platform, SocialAccount.platform_user_id, SocialAccount.platform_username, ] - column_default_sort = (SocialAccount.connected_at, True) + column_default_sort = (SocialAccount.created_at, True) column_sortable_list = [ SocialAccount.id, - SocialAccount.user_id, + SocialAccount.user_uuid, SocialAccount.platform, SocialAccount.is_active, - SocialAccount.connected_at, + SocialAccount.is_deleted, + SocialAccount.created_at, ] column_labels = { "id": "ID", - "user_id": "사용자 ID", + "user_uuid": "사용자 UUID", "platform": "플랫폼", "platform_user_id": "플랫폼 사용자 ID", "platform_username": "플랫폼 사용자명", @@ -210,6 +213,7 @@ class SocialAccountAdmin(ModelView, model=SocialAccount): "scope": "권한 범위", "token_expires_at": "토큰 만료일시", "is_active": "활성화", - "connected_at": "연동일시", + "is_deleted": "삭제됨", + "created_at": "생성일시", "updated_at": "수정일시", } diff --git a/app/user/dependencies/auth.py b/app/user/dependencies/auth.py index a798854..5074a9d 100644 --- a/app/user/dependencies/auth.py +++ b/app/user/dependencies/auth.py @@ -12,15 +12,14 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session -from app.user.exceptions import ( +from app.user.models import User +from app.user.services.auth import ( AdminRequiredError, InvalidTokenError, MissingTokenError, - TokenExpiredError, UserInactiveError, UserNotFoundError, ) -from app.user.models import User from app.user.services.jwt import decode_token security = HTTPBearer(auto_error=False) diff --git a/app/user/exceptions.py b/app/user/exceptions.py deleted file mode 100644 index 310f949..0000000 --- a/app/user/exceptions.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -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, - ) diff --git a/app/user/models.py b/app/user/models.py index 58a81e6..fa9848f 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -5,6 +5,7 @@ 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 @@ -13,6 +14,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base + if TYPE_CHECKING: from app.home.models import Project @@ -403,6 +405,15 @@ class RefreshToken(Base): ) +class Platform(str, Enum): + """소셜 플랫폼 구분""" + + YOUTUBE = "youtube" + INSTAGRAM = "instagram" + FACEBOOK = "facebook" + TIKTOK = "tiktok" + + class SocialAccount(Base): """ 소셜 계정 연동 테이블 @@ -475,10 +486,10 @@ class SocialAccount(Base): # ========================================================================== # 플랫폼 구분 # ========================================================================== - platform: Mapped[str] = mapped_column( + platform: Mapped[Platform] = mapped_column( String(20), nullable=False, - comment="플랫폼 구분 (youtube, instagram, facebook)", + comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)", ) # ========================================================================== @@ -513,7 +524,7 @@ class SocialAccount(Base): # ========================================================================== platform_user_id: Mapped[str] = mapped_column( String(100), - nullable=False, + nullable=True, comment="플랫폼 내 사용자 고유 ID", ) @@ -539,7 +550,7 @@ class SocialAccount(Base): Boolean, nullable=False, default=True, - comment="연동 활성화 상태 (비활성화 시 사용 중지)", + comment="활성화 상태 (비활성화 시 사용 중지)", ) is_deleted: Mapped[bool] = mapped_column( @@ -552,13 +563,6 @@ class SocialAccount(Base): # ========================================================================== # 시간 정보 # ========================================================================== - connected_at: Mapped[datetime] = mapped_column( - DateTime, - nullable=False, - server_default=func.now(), - comment="연동 일시", - ) - updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, @@ -567,6 +571,13 @@ class SocialAccount(Base): comment="정보 수정 일시", ) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + # ========================================================================== # User 관계 # ========================================================================== diff --git a/app/user/schemas/social_account_schema.py b/app/user/schemas/social_account_schema.py new file mode 100644 index 0000000..1ee6b10 --- /dev/null +++ b/app/user/schemas/social_account_schema.py @@ -0,0 +1,149 @@ +""" +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 + } + } + } diff --git a/app/user/services/auth.py b/app/user/services/auth.py index 1071ff7..bdea105 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -8,6 +8,7 @@ import logging from datetime import datetime from typing import Optional +from fastapi import HTTPException, status from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -16,13 +17,66 @@ from config import prj_settings logger = logging.getLogger(__name__) -from app.user.exceptions import ( - InvalidTokenError, - TokenExpiredError, - TokenRevokedError, - UserInactiveError, - UserNotFoundError, -) + +# ============================================================================= +# 인증 예외 클래스 정의 +# ============================================================================= +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.models import RefreshToken, User from app.utils.common import generate_uuid from app.user.schemas.user_schema import ( diff --git a/app/user/services/kakao.py b/app/user/services/kakao.py index 7231573..8631fef 100644 --- a/app/user/services/kakao.py +++ b/app/user/services/kakao.py @@ -7,15 +7,39 @@ 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 클라이언트 diff --git a/app/user/services/social_account.py b/app/user/services/social_account.py new file mode 100644 index 0000000..c44ee65 --- /dev/null +++ b/app/user/services/social_account.py @@ -0,0 +1,259 @@ +""" +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) diff --git a/app/utils/instagram.py b/app/utils/instagram.py new file mode 100644 index 0000000..ebb680c --- /dev/null +++ b/app/utils/instagram.py @@ -0,0 +1,398 @@ +""" +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 diff --git a/app/utils/upload_blob_as_request.py b/app/utils/upload_blob_as_request.py index 8ca2132..7012f76 100644 --- a/app/utils/upload_blob_as_request.py +++ b/app/utils/upload_blob_as_request.py @@ -32,6 +32,7 @@ URL 경로 형식: """ import asyncio +import re import time from pathlib import Path @@ -129,6 +130,33 @@ class AzureBlobUploader: """마지막 업로드의 공개 URL (SAS 토큰 제외)""" return self._last_public_url + def _sanitize_filename(self, file_name: str) -> str: + """파일명에서 공백/특수문자 제거, 한글/영문/숫자만 허용 + + Args: + file_name: 원본 파일명 + + Returns: + str: 정리된 파일명 (한글, 영문, 숫자만 포함) + + Example: + >>> self._sanitize_filename("my file (1).mp4") + 'myfile1.mp4' + >>> self._sanitize_filename("테스트 파일!@#.png") + '테스트파일.png' + """ + stem = Path(file_name).stem + suffix = Path(file_name).suffix + + # 한글(가-힣), 영문(a-zA-Z), 숫자(0-9)만 남기고 제거 + sanitized = re.sub(r'[^가-힣a-zA-Z0-9]', '', stem) + + # 빈 문자열이면 기본값 사용 + if not sanitized: + sanitized = "file" + + return f"{sanitized}{suffix}" + def _build_upload_url(self, category: str, file_name: str) -> str: """업로드 URL 생성 (SAS 토큰 포함)""" # SAS 토큰 앞뒤의 ?, ', " 제거 @@ -238,8 +266,8 @@ class AzureBlobUploader: Returns: bool: 업로드 성공 여부 """ - # 파일 경로에서 파일명 추출 - file_name = Path(file_path).name + # 파일 경로에서 파일명 추출 후 정리 (공백/특수문자 제거) + file_name = self._sanitize_filename(Path(file_path).name) upload_url = self._build_upload_url(category, file_name) self._last_public_url = self._build_public_url(category, file_name) @@ -301,7 +329,8 @@ class AzureBlobUploader: success = await uploader.upload_music_bytes(audio_bytes, "my_song") print(uploader.public_url) """ - # 확장자가 없으면 .mp3 추가 + # 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp3 추가 + file_name = self._sanitize_filename(file_name) if not Path(file_name).suffix: file_name = f"{file_name}.mp3" @@ -363,7 +392,8 @@ class AzureBlobUploader: success = await uploader.upload_video_bytes(video_bytes, "my_video") print(uploader.public_url) """ - # 확장자가 없으면 .mp4 추가 + # 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp4 추가 + file_name = self._sanitize_filename(file_name) if not Path(file_name).suffix: file_name = f"{file_name}.mp4" @@ -430,9 +460,13 @@ class AzureBlobUploader: success = await uploader.upload_image_bytes(content, "my_image.png") print(uploader.public_url) """ + # Content-Type 결정을 위해 먼저 확장자 추출 extension = Path(file_name).suffix.lower() content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") + # 파일명 정리 (공백/특수문자 제거) + file_name = self._sanitize_filename(file_name) + upload_url = self._build_upload_url("image", file_name) self._last_public_url = self._build_public_url("image", file_name) log_prefix = "upload_image_bytes" diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index bb90dc9..2d48764 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -558,23 +558,15 @@ async def get_video_status( if video and video.status != "completed": # 이미 완료된 경우 백그라운드 작업 중복 실행 방지 - # task_id로 Project 조회하여 store_name 가져오기 - project_result = await session.execute( - select(Project).where(Project.id == video.project_id) - ) - project = project_result.scalar_one_or_none() - - store_name = project.store_name if project else "video" - # 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제 logger.info( - f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}, creatomate_render_id: {creatomate_render_id}" + f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, creatomate_render_id: {creatomate_render_id}" ) background_tasks.add_task( download_and_upload_video_to_blob, task_id=video.task_id, video_url=video_url, - store_name=store_name, + creatomate_render_id=creatomate_render_id, user_uuid=current_user.user_uuid, creatomate_render_id=creatomate_render_id, ) diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index a9f544e..1eda58c 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -105,29 +105,27 @@ async def _download_video(url: str, task_id: str) -> bytes: async def download_and_upload_video_to_blob( task_id: str, video_url: str, - store_name: str, + creatomate_render_id: str, user_uuid: str, creatomate_render_id: str | None = None, ) -> None: """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. + 파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다. + Args: task_id: 프로젝트 task_id video_url: 다운로드할 영상 URL - store_name: 저장할 파일명에 사용할 업체명 + creatomate_render_id: Creatomate API 렌더 ID (파일명으로 사용) user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) creatomate_render_id: Creatomate 렌더 ID (특정 Video 식별용) """ - logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") + logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}") temp_file_path: Path | None = None try: - # 파일명에 사용할 수 없는 문자 제거 - safe_store_name = "".join( - c for c in store_name if c.isalnum() or c in (" ", "_", "-") - ).strip() - safe_store_name = safe_store_name or "video" - file_name = f"{safe_store_name}.mp4" + # creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요) + file_name = f"{creatomate_render_id}.mp4" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id @@ -193,18 +191,18 @@ async def download_and_upload_video_to_blob( async def download_and_upload_video_by_creatomate_render_id( creatomate_render_id: str, video_url: str, - store_name: str, user_uuid: str, ) -> None: """creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. + 파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다. + Args: - creatomate_render_id: Creatomate API 렌더 ID + creatomate_render_id: Creatomate API 렌더 ID (파일명으로도 사용) video_url: 다운로드할 영상 URL - store_name: 저장할 파일명에 사용할 업체명 user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) """ - logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") + logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}") temp_file_path: Path | None = None task_id: str | None = None @@ -226,12 +224,8 @@ async def download_and_upload_video_by_creatomate_render_id( task_id = video.task_id logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {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 "video" - file_name = f"{safe_store_name}.mp4" + # creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요) + file_name = f"{creatomate_render_id}.mp4" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id diff --git a/docs/plan/instagram-plan.md b/docs/plan/instagram-plan.md new file mode 100644 index 0000000..bb52036 --- /dev/null +++ b/docs/plan/instagram-plan.md @@ -0,0 +1,238 @@ +# Instagram POC 개발 계획서 + +## 프로젝트 개요 + +Instagram Graph API를 사용하여 이미지, 영상, 컨텐츠를 업로드하고 결과를 확인하는 POC 모듈 개발 + +## 목표 + +- 단일 파일, 단일 클래스로 Instagram API 기능 구현 +- 여러 사용자가 각자의 계정으로 컨텐츠 업로드 가능 (멀티테넌트) +- API 예외처리 및 에러 핸들링 +- 테스트 파일 및 사용 매뉴얼 제공 + +## 참고 자료 + +1. **기존 코드**: `poc/instagram1/` 폴더 +2. **공식 문서**: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing + +## 기존 코드(instagram1) 분석 결과 + +### 발견된 문제점 (모두 수정 완료 ✅) + +| 심각도 | 문제 | 설명 | 상태 | +|--------|------|------|------| +| 🔴 Critical | 잘못된 import 경로 | `poc.instagram` → `poc.instagram1`로 수정 | ✅ 완료 | +| 🔴 Critical | Timezone 혼합 | `datetime.now()` → `datetime.now(timezone.utc)`로 수정 | ✅ 완료 | +| 🟡 Warning | 파일 중간 import | `config.py`에서 `lru_cache` import를 파일 상단으로 이동 | ✅ 완료 | +| 🟡 Warning | Deprecated alias 사용 | `PermissionError` → `InstagramPermissionError`로 변경 | ✅ 완료 | +| 🟡 Warning | docstring 경로 오류 | `__init__.py` 예제 경로 수정 | ✅ 완료 | + +### 수정된 파일 + +- `poc/instagram1/config.py` - import 위치 수정 +- `poc/instagram1/__init__.py` - docstring 경로 수정 +- `poc/instagram1/examples/auth_example.py` - timezone 및 import 경로 수정 +- `poc/instagram1/examples/account_example.py` - import 경로 수정 +- `poc/instagram1/examples/comments_example.py` - import 경로 수정 +- `poc/instagram1/examples/insights_example.py` - import 경로 및 deprecated alias 수정 +- `poc/instagram1/examples/media_example.py` - import 경로 수정 + +## 산출물 + +``` +poc/instagram/ +├── client.py # InstagramClient 클래스 + 예외 클래스 +├── main.py # 테스트 실행 파일 +└── poc.md # 사용 매뉴얼 +``` + +## 필수 기능 + +1. **이미지 업로드** - 단일 이미지 게시 +2. **영상(릴스) 업로드** - 비디오 게시 +3. **캐러셀 업로드** - 멀티 이미지 게시 (2-10개) +4. **미디어 조회** - 업로드된 게시물 확인 +5. **예외처리** - API 에러 코드별 처리 + +## 설계 요구사항 + +### 클래스 구조 + +```python +class InstagramClient: + """ + Instagram Graph API 클라이언트 + + - 인스턴스 생성 시 access_token 전달 (멀티테넌트 지원) + - 비동기 컨텍스트 매니저 패턴 + """ + + def __init__(self, access_token: str): ... + + async def publish_image(self, image_url: str, caption: str) -> Media: ... + async def publish_video(self, video_url: str, caption: str) -> Media: ... + async def publish_carousel(self, media_urls: list[str], caption: str) -> Media: ... + async def get_media(self, media_id: str) -> Media: ... + async def get_media_list(self, limit: int) -> list[Media]: ... +``` + +### 예외 클래스 + +```python +class InstagramAPIError(Exception): ... # 기본 예외 +class AuthenticationError(InstagramAPIError): ... # 인증 오류 +class RateLimitError(InstagramAPIError): ... # Rate Limit 초과 +class MediaPublishError(InstagramAPIError): ... # 게시 실패 +class InvalidRequestError(InstagramAPIError): ... # 잘못된 요청 +``` + +--- + +## 에이전트 워크플로우 + +### 1단계: 설계 에이전트 (`/design`) + +``` +/design + +## 요청 개요 +Instagram Graph API를 사용하여 이미지, 영상, 컨텐츠를 업로드하고 결과를 확인하는 POC 모듈 설계 + +## 참고 자료 +1. Instagram Graph API 공식 문서: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing +2. 기존 코드: poc/instagram1/ 폴더 내용 + +## 요구사항 +1. poc/instagram/ 폴더에 단일 파일, 단일 클래스로 구현 +2. 여러 사용자가 각자의 계정으로 컨텐츠 업로드 가능하도록 설계 (멀티테넌트) +3. 필수 기능: + - 이미지 업로드 + - 영상(릴스) 업로드 + - 캐러셀(멀티 이미지) 업로드 + - 업로드된 미디어 조회 +4. Instagram API 에러 코드별 예외처리 +5. main.py 테스트 파일 포함 +6. poc.md 사용 매뉴얼 문서 포함 + +## 산출물 +- 클래스 구조 및 메서드 시그니처 +- 예외 클래스 설계 +- 파일 구조 +``` + +--- + +### 2단계: 개발 에이전트 (`/develop`) + +``` +/develop + +## 작업 내용 +1단계 설계를 기반으로 Instagram POC 모듈 구현 + +## 구현 대상 +1. poc/instagram/client.py + - InstagramClient 클래스 (단일 클래스로 모든 기능 포함) + - 예외 클래스들 (같은 파일 내 정의) + - 기능별 주석 필수 + +2. poc/instagram/main.py + - 테스트 코드 (import하여 각 기능 테스트) + - 환경변수 기반 토큰 설정 + +3. poc/instagram/poc.md + - 동작 원리 설명 + - 환경 설정 방법 + - 사용 예제 코드 + - API 제한사항 + +## 참고 +- poc/instagram1/ 폴더의 기존 코드 참고 +- Instagram Graph API 공식 문서 기반으로 구현 +- 잘못된 import 경로, timezone 문제 등 기존 코드의 버그 수정 반영 +``` + +--- + +### 3단계: 코드리뷰 에이전트 (`/review`) + +``` +/review + +## 리뷰 대상 +poc/instagram/ 폴더의 모든 파일 + +## 리뷰 항목 +1. 코드 품질 + - PEP 8 준수 여부 + - 타입 힌트 적용 + - 비동기 패턴 적절성 + +2. 기능 완성도 + - 이미지/영상/캐러셀 업로드 기능 + - 미디어 조회 기능 + - 멀티테넌트 지원 + +3. 예외처리 + - API 에러 코드별 처리 + - Rate Limit 처리 + - 타임아웃 처리 + +4. 문서화 + - 주석의 적절성 + - poc.md 완성도 + +5. 보안 + - 토큰 노출 방지 + - 민감 정보 로깅 마스킹 + +## instagram1 대비 개선점 확인 +- import 경로 오류 수정됨 +- timezone aware/naive 혼합 문제 수정됨 +- deprecated alias 제거됨 +``` + +--- + +## 실행 순서 + +```bash +# 1. 설계 단계 +/design + +# 2. 개발 단계 (설계 승인 후) +/develop + +# 3. 코드 리뷰 단계 (개발 완료 후) +/review +``` + +--- + +## 환경 설정 + +### 필수 환경변수 + +```bash +export INSTAGRAM_ACCESS_TOKEN="your_access_token" +export INSTAGRAM_APP_ID="your_app_id" # 선택 +export INSTAGRAM_APP_SECRET="your_app_secret" # 선택 +``` + +### 의존성 + +```bash +uv add httpx pydantic pydantic-settings +``` + +--- + +## 일정 + +| 단계 | 작업 | 상태 | +|------|------|------| +| 1 | 설계 (`/design`) | ✅ 완료 | +| 2 | 개발 (`/develop`) | ✅ 완료 | +| 3 | 코드리뷰 (`/review`) | ⬜ 대기 | +| 4 | 테스트 및 검증 | ⬜ 대기 | diff --git a/main.py b/main.py index 60fcf3f..db7b88d 100644 --- a/main.py +++ b/main.py @@ -14,8 +14,10 @@ from app.user.models import User, RefreshToken # noqa: F401 from app.archive.api.routers.v1.archive import router as archive_router from app.home.api.routers.v1.home import router as home_router from app.user.api.routers.v1.auth import router as auth_router, test_router as auth_test_router +from app.user.api.routers.v1.social_account import router as social_account_router from app.lyric.api.routers.v1.lyric import router as lyric_router from app.song.api.routers.v1.song import router as song_router +from app.sns.api.routers.v1.sns import router as sns_router from app.video.api.routers.v1.video import router as video_router from app.social.api.routers.v1.oauth import router as social_oauth_router from app.social.api.routers.v1.upload import router as social_upload_router @@ -32,7 +34,7 @@ tags_metadata = [ 1. `GET /user/auth/kakao/login` - 카카오 로그인 URL 획득 2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인 3. 카카오에서 인가 코드(code) 발급 -4. `POST /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급 +4. `GET /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급 (카카오 리다이렉트) 5. 이후 API 호출 시 `Authorization: Bearer {access_token}` 헤더 사용 ## 토큰 관리 @@ -47,6 +49,21 @@ tags_metadata = [ 3. `access_token` 값 입력 (Bearer 접두사 없이 토큰만 입력) 4. **Authorize** 클릭하여 저장 5. 이후 인증이 필요한 API 호출 시 자동으로 토큰이 포함됨 +""", + }, + { + "name": "Social Account", + "description": """소셜 계정 연동 API - YouTube, Instagram, Facebook, TikTok + +**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수 + +## 주요 기능 + +- `GET /user/social-accounts` - 연동된 소셜 계정 목록 조회 +- `GET /user/social-accounts/{account_id}` - 소셜 계정 상세 조회 +- `POST /user/social-accounts` - 소셜 계정 연동 +- `PATCH /user/social-accounts/{account_id}` - 소셜 계정 정보 수정 +- `DELETE /user/social-accounts/{account_id}` - 소셜 계정 연동 해제 """, }, # { @@ -202,6 +219,23 @@ tags_metadata = [ - `processing`: 플랫폼에서 처리 중 - `completed`: 업로드 완료 - `failed`: 업로드 실패 +""", + }, + { + "name": "SNS", + "description": """SNS 업로드 API - Instagram Graph API + +**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수 + +## 주요 기능 + +- `POST /sns/instagram/upload/{task_id}` - task_id에 해당하는 비디오를 Instagram에 업로드 + +## Instagram 업로드 흐름 + +1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조) +2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다 +3. 업로드 성공 시 Instagram media_id와 permalink 반환 """, }, ] @@ -278,7 +312,7 @@ def custom_openapi(): if method in ["get", "post", "put", "patch", "delete"]: # 공개 엔드포인트가 아닌 경우 인증 필요 is_public = any(public_path in path for public_path in public_endpoints) - if not is_public and path.startswith("/api/"): + if not is_public: operation["security"] = [{"BearerAuth": []}] app.openapi_schema = openapi_schema @@ -319,12 +353,14 @@ add_exception_handlers(app) app.include_router(home_router) app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가 +app.include_router(social_account_router, prefix="/user") # Social Account API 라우터 추가 app.include_router(lyric_router) app.include_router(song_router) app.include_router(video_router) app.include_router(archive_router) # Archive API 라우터 추가 app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가 app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가 +app.include_router(sns_router) # SNS API 라우터 추가 # DEBUG 모드에서만 테스트 라우터 등록 if prj_settings.DEBUG: diff --git a/main_tester.ipynb b/main_tester.ipynb deleted file mode 100644 index 10d2510..0000000 --- a/main_tester.ipynb +++ /dev/null @@ -1,694 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "e7af5103-62db-4a32-b431-6395c85d7ac9", - "metadata": {}, - "outputs": [], - "source": [ - "from app.home.api.routers.v1.home import crawling\n", - "from app.utils.prompts import prompts" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "6cf7ae9b-3ffe-4046-9cab-f33bc071b288", - "metadata": {}, - "outputs": [], - "source": [ - "from config import crawler_settings" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "4c4ec4c5-9efb-470f-99cf-a18a5b80352f", - "metadata": {}, - "outputs": [], - "source": [ - "from app.home.schemas.home_schema import (\n", - " CrawlingRequest,\n", - " CrawlingResponse,\n", - " ErrorResponse,\n", - " ImageUploadResponse,\n", - " ImageUploadResultItem,\n", - " ImageUrlItem,\n", - " MarketingAnalysis,\n", - " ProcessedInfo,\n", - ")\n", - "import json" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "be5d0e16-8cc6-44d4-ae93-8252caa09940", - "metadata": {}, - "outputs": [], - "source": [ - "val1 = CrawlingRequest(**{\"url\" : 'https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPanelNum=1&additionalHeight=76×tamp=202601131552&locale=ko&svcName=map_pcv5&businessCategory=pension&c=15.00,0,0,0,dh'})" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "c13742d7-70f4-4a6d-90c2-8b84f245a08c", - "metadata": {}, - "outputs": [], - "source": [ - "from app.utils.prompts.prompts import reload_all_prompt\n", - "reload_all_prompt()" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "d4db2ec1-b2af-4993-8832-47f380c17015", - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[2026-01-19 14:13:53] [INFO] [home:crawling:110] [crawling] ========== START ==========\n", - "[2026-01-19 14:13:53] [INFO] [home:crawling:111] [crawling] URL: https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPane...\n", - "[2026-01-19 14:13:53] [INFO] [home:crawling:115] [crawling] Step 1: 네이버 지도 크롤링 시작...\n", - "[2026-01-19 14:13:53] [INFO] [scraper:_call_get_accommodation:140] [NvMapScraper] Requesting place_id: 1903455560\n", - "[2026-01-19 14:13:53] [INFO] [scraper:_call_get_accommodation:149] [NvMapScraper] SUCCESS - place_id: 1903455560\n", - "[2026-01-19 14:13:51] [INFO] [home:crawling:138] [crawling] Step 1 완료 - 이미지 44개 (735.1ms)\n", - "[2026-01-19 14:13:51] [INFO] [home:crawling:142] [crawling] Step 2: 정보 가공 시작...\n", - "[2026-01-19 14:13:51] [INFO] [home:crawling:159] [crawling] Step 2 완료 - 오블로모프, 군산시 (0.8ms)\n", - "[2026-01-19 14:13:51] [INFO] [home:crawling:163] [crawling] Step 3: ChatGPT 마케팅 분석 시작...\n", - "[2026-01-19 14:13:51] [DEBUG] [home:crawling:170] [crawling] Step 3-1: 서비스 초기화 완료 (428.6ms)\n", - "build_template \n", - "[Role & Objective]\n", - "Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.\n", - "Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.\n", - "The report must clearly explain what makes the property sellable, marketable, and scalable through content.\n", - "\n", - "[INPUT]\n", - "- Business Name: {customer_name}\n", - "- Region: {region}\n", - "- Region Details: {detail_region_info}\n", - "\n", - "[Core Analysis Requirements]\n", - "Analyze the property based on:\n", - "Location, concept, and nearby environment\n", - "Target customer behavior and reservation decision factors\n", - "Include:\n", - "- Target customer segments & personas\n", - "- Unique Selling Propositions (USPs)\n", - "- Competitive landscape (direct & indirect competitors)\n", - "- Market positioning\n", - "\n", - "[Key Selling Point Structuring – UI Optimized]\n", - "From the analysis above, extract the main Key Selling Points using the structure below.\n", - "Rules:\n", - "Focus only on factors that directly influence booking decisions\n", - "Each selling point must be concise and visually scannable\n", - "Language must be reusable for ads, short-form videos, and listing headlines\n", - "Avoid full sentences in descriptions; use short selling phrases\n", - "Do not provide in report\n", - "\n", - "Output format:\n", - "[Category]\n", - "(Tag keyword – 5~8 words, noun-based, UI oval-style)\n", - "One-line selling phrase (not a full sentence)\n", - "Limit:\n", - "5 to 8 Key Selling Points only\n", - "Do not provide in report\n", - "\n", - "[Content & Automation Readiness Check]\n", - "Ensure that:\n", - "Each tag keyword can directly map to a content theme\n", - "Each selling phrase can be used as:\n", - "- Video hook\n", - "- Image headline\n", - "- Ad copy snippet\n", - "\n", - "\n", - "[Tag Generation Rules]\n", - "- Tags must include **only core keywords that can be directly used for viral video song lyrics**\n", - "- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind\n", - "- The number of tags must be **exactly 5**\n", - "- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited\n", - "- The following categories must be **balanced and all represented**:\n", - " 1) **Location / Local context** (region name, neighborhood, travel context)\n", - " 2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)\n", - " 3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)\n", - " 4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)\n", - " 5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)\n", - "\n", - "- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**\n", - "- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**\n", - "- The final output must strictly follow the JSON format below, with no additional text\n", - "\n", - " \"tags\": [\"Tag1\", \"Tag2\", \"Tag3\", \"Tag4\", \"Tag5\"]\n", - "\n", - "input_data {'customer_name': '오블로모프', 'region': '군산시', 'detail_region_info': '전북 군산시 절골길 16'}\n", - "[ChatgptService] Generated Prompt (length: 2791)\n", - "[2026-01-19 14:13:51] [INFO] [chatgpt:generate_structured_output:43] [ChatgptService] Starting GPT request with structured output with model: gpt-5-mini\n", - "[2026-01-19 14:14:52] [INFO] [home:crawling:187] [crawling] Step 3-3: GPT API 호출 완료 - (63233.5ms)\n", - "[2026-01-19 14:14:52] [DEBUG] [home:crawling:188] [crawling] Step 3-3: GPT API 호출 완료 - (63233.5ms)\n", - "[2026-01-19 14:14:52] [DEBUG] [home:crawling:193] [crawling] Step 3-4: 응답 파싱 시작 - facility_info: 무선 인터넷, 예약, 주차\n", - "[2026-01-19 14:14:52] [DEBUG] [home:crawling:212] [crawling] Step 3-4: 응답 파싱 완료 (2.1ms)\n", - "[2026-01-19 14:14:52] [INFO] [home:crawling:215] [crawling] Step 3 완료 - 마케팅 분석 성공 (63670.2ms)\n", - "[2026-01-19 14:14:52] [INFO] [home:crawling:229] [crawling] ========== COMPLETE ==========\n", - "[2026-01-19 14:14:52] [INFO] [home:crawling:230] [crawling] 총 소요시간: 64412.0ms\n", - "[2026-01-19 14:14:52] [INFO] [home:crawling:231] [crawling] - Step 1 (크롤링): 735.1ms\n", - "[2026-01-19 14:14:52] [INFO] [home:crawling:233] [crawling] - Step 2 (정보가공): 0.8ms\n", - "[2026-01-19 14:14:52] [INFO] [home:crawling:235] [crawling] - Step 3 (GPT 분석): 63670.2ms\n", - "[2026-01-19 14:14:52] [INFO] [home:crawling:237] [crawling] - GPT API 호출: 63233.5ms\n" - ] - } - ], - "source": [ - "var2 = await crawling(val1)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "79f093f0-d7d2-4ed1-ba43-da06e4ee2073", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'image_list': ['https://ldb-phinf.pstatic.net/20230515_163/1684090233619kRU3v_JPEG/20230513_154207.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20250811_213/17548982879808X4MH_PNG/1.png',\n", - " 'https://ldb-phinf.pstatic.net/20240409_34/1712622373542UY8aC_JPEG/20231007_051403.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_37/1684090234513tT89X_JPEG/20230513_152018.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20241231_272/1735620966755B9XgT_PNG/DSC09054.png',\n", - " 'https://ldb-phinf.pstatic.net/20240409_100/1712622410472zgP15_JPEG/20230523_153219.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_151/1712623034401FzQbd_JPEG/Screenshot_20240409_093158_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_169/1712622316504ReKji_JPEG/20230728_125946.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230521_279/1684648422643NI2oj_JPEG/20230521_144343.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_52/1712622993632WR1sT_JPEG/Screenshot_20240409_093237_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20250811_151/1754898220223TNtvB_PNG/2.png',\n", - " 'https://ldb-phinf.pstatic.net/20240409_70/1712622381167p9QOI_JPEG/20230608_175722.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_144/1684090233161cR5mr_JPEG/20230513_180151.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_158/1712621983956CCqdo_JPEG/20240407_121826.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20250811_187/1754893113769iGO5X_JPEG/%B0%C5%BD%C7_01.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_31/17126219901822nnR4_JPEG/20240407_121615.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_94/1712621993863AWMKi_JPEG/20240407_121520.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_165/1684090236297fVhJM_JPEG/20230513_165348.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_102/1684090230350e1v0E_JPEG/20230513_162718.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_26/1684090232743arN2y_JPEG/20230513_174246.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20250811_273/1754893072358V3WcL_JPEG/%B5%F0%C5%D7%C0%CF%C4%C6_02.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_160/1712621974438LLNbD_JPEG/20240407_121848.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_218/1712623006036U39zE_JPEG/Screenshot_20240409_093114_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_210/16840902342654EkeL_JPEG/20230513_152107.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_216/1712623058832HBulg_JPEG/Screenshot_20240409_093309_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_184/1684090223226nO2Az_JPEG/20230514_143325.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_209/1684090697642BHNVR_JPEG/20230514_143528.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_16/1712623029052VNeaz_JPEG/Screenshot_20240409_093141_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_141/1684090233092KwtWy_JPEG/20230513_180105.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_177/1712623066424dcwJ2_JPEG/Screenshot_20240409_093511_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_181/16840902259407iA5Q_JPEG/20230514_144814.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_153/1684090224581Ih4ft_JPEG/20230514_143552.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_205/1684090231467WmulO_JPEG/20230513_180254.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20230515_120/1684090231233PkqCf_JPEG/20230513_152550.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_188/1712623039909sflvy_JPEG/Screenshot_20240409_093209_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_165/1712623049073j0TzM_JPEG/Screenshot_20240409_093254_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_3/17126230950579050V_JPEG/Screenshot_20240409_093412_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_270/1712623091524YX4E6_JPEG/Screenshot_20240409_093355_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_22/1712623083348btwTB_JPEG/Screenshot_20240409_093331_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_242/1712623087423Q7tHk_JPEG/Screenshot_20240409_093339_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_173/1712623098958aFhiB_JPEG/Screenshot_20240409_093422_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_113/1712623103270DOGKI_JPEG/Screenshot_20240409_093435_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_295/17126230704056BTRg_JPEG/Screenshot_20240409_093448_Airbnb.jpg',\n", - " 'https://ldb-phinf.pstatic.net/20240409_178/1712623075172JEt43_JPEG/Screenshot_20240409_093457_Airbnb.jpg'],\n", - " 'image_count': 44,\n", - " 'processed_info': ProcessedInfo(customer_name='오블로모프', region='군산시', detail_region_info='전북 군산시 절골길 16'),\n", - " 'marketing_analysis': MarketingAnalysis(report=MarketingAnalysisReport(summary=\"오블로모프는 '느림·쉼·문학적 감성'을 브랜드 콘셉트로 삼아 전북 군산시 절골길 인근의 조용한 주거·근대문화 접근성을 살린 소규모 부티크 스테이입니다. 도심형 접근성과 지역 근대문화·항구 관광지를 결합해 주말 단기체류, 커플·소규모 그룹, 콘텐츠 크리에이터 수요를 공략할 수 있습니다. 핵심은 브랜드 스토리(‘Oblomov’의 느긋함)와 인스타형 비주얼, 지역 연계 체험 상품으로 예약전환을 높이는 것입니다.\", details=[MarketingAnalysisDetail(detail_title='입지·콘셉트·주변 환경', detail_description='절골길 인근의 주택가·언덕형 지형, 조용한 체류 환경. 군산 근대역사문화거리·항구·현지 시장 접근권(차로 10–25분권). 문학적·레트로 감성 콘셉트(오블로모프 → 느림·휴식)으로 도심형 ‘감성 은신처’ 포지셔닝 가능.'), MarketingAnalysisDetail(detail_title='예약 결정 요인(고객 행동)', detail_description='사진·비주얼(첫 인상) → 콘셉트·프라이버시(전용공간 여부) → 접근성(차·대중교통 소요) → 가격 대비 가치·후기 → 체크인 편의성(셀프체크인 여부) → 지역 체험(먹거리·근대문화 투어) 순으로 예약 전환 영향.'), MarketingAnalysisDetail(detail_title='타깃 고객 세그먼트 & 페르소나', detail_description='1) 20–40대 커플: 주말 단기여행, 인생샷·감성 중심. 2) 20–30대 SNS 크리에이터/프리랜서: 콘텐츠·촬영지 탐색. 3) 소규모 가족·친구 그룹: 편안한 휴식·지역먹거리 체험. 4) 도심 직장인(원데이캉스): 근교 드라이브·힐링 목적.'), MarketingAnalysisDetail(detail_title='주요 USP(차별화 포인트)', detail_description='브랜드 스토리(‘Oblomov’ 느림의 미학), 군산 근대문화·항구 접근성, 소규모 부티크·프라이빗 체류감, 감성 포토존·인테리어로 SNS 확산 가능, 지역 먹거리·투어 연계로 체류 체감 가치 상승.'), MarketingAnalysisDetail(detail_title='경쟁 환경(직·간접 경쟁)', detail_description=\"직접: 군산 내 펜션·게스트하우스·한옥스테이(근대문화거리·항구 인근). 간접: 근교 글램핑·리조트·카페형 숙소, 당일투어(시장·박물관)로 체류대체 가능. 경쟁 우위는 '문학적 느림' 콘셉트+인스타블 친화적 비주얼.\"), MarketingAnalysisDetail(detail_title='시장 포지셔닝 제안', detail_description=\"중간 가격대의 부티크 스테이(가성비+감성), '주말 힐링·감성 촬영지' 중심 마케팅. 타깃 채널: 네이버 예약·에어비앤비·인스타그램·유튜브 숏폼. 지역 협업(카페·투어·해산물 체험)으로 패키지화.\")]), tags=['군산오블로모프', '부티크스테이', '힐링타임', '인생샷스팟', '주말여행'], facilities=['군산 근대거리·항구 근접', '문학적 느림·부티크 스테이', '프라이빗 객실·소규모 전용감', '감성 포토존·인테리어', '해산물·시장·근대투어 연계', '주말 단기여행·원데이캉스 수요'])}" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "var2" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "f3bf1d76-bd2a-43d5-8d39-f0ab2459701a", - "metadata": {}, - "outputs": [ - { - "ename": "KeyError", - "evalue": "'selling_points'", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mKeyError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[8]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m i \u001b[38;5;129;01min\u001b[39;00m \u001b[43mvar2\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mselling_points\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m]\u001b[49m:\n\u001b[32m 2\u001b[39m \u001b[38;5;28mprint\u001b[39m(i[\u001b[33m'\u001b[39m\u001b[33mcategory\u001b[39m\u001b[33m'\u001b[39m])\n\u001b[32m 3\u001b[39m \u001b[38;5;28mprint\u001b[39m(i[\u001b[33m'\u001b[39m\u001b[33mkeywords\u001b[39m\u001b[33m'\u001b[39m])\n", - "\u001b[31mKeyError\u001b[39m: 'selling_points'" - ] - } - ], - "source": [ - "for i in var2[\"selling_points\"]:\n", - " print(i['category'])\n", - " print(i['keywords'])\n", - " print(i['description'])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c89cf2eb-4f16-4dc5-90c6-df89191b4e39", - "metadata": {}, - "outputs": [], - "source": [ - "var2[\"selling_points\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "231963d6-e209-41b3-8e78-2ad5d06943fe", - "metadata": {}, - "outputs": [], - "source": [ - "var2[\"tags\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f8260222-d5a2-4018-b465-a4943c82bd3f", - "metadata": {}, - "outputs": [], - "source": [ - "lyric_prompt = \"\"\"\n", - "[ROLE]\n", - "You are a content marketing expert, brand strategist, and creative songwriter\n", - "specializing in Korean pension / accommodation businesses.\n", - "You create lyrics strictly based on Brand & Marketing Intelligence analysis\n", - "and optimized for viral short-form video content.\n", - "\n", - "[INPUT]\n", - "Business Name: {customer_name}\n", - "Region: {region}\n", - "Region Details: {detail_region_info}\n", - "Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n", - "Output Language: {language}\n", - "\n", - "[INTERNAL ANALYSIS – DO NOT OUTPUT]\n", - "Internally analyze the following to guide all creative decisions:\n", - "- Core brand identity and positioning\n", - "- Emotional hooks derived from selling points\n", - "- Target audience lifestyle, desires, and travel motivation\n", - "- Regional atmosphere and symbolic imagery\n", - "- How the stay converts into “shareable moments”\n", - "- Which selling points must surface implicitly in lyrics\n", - "\n", - "[LYRICS & MUSIC CREATION TASK]\n", - "Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n", - "- Original promotional lyrics\n", - "- Music attributes for AI music generation (Suno-compatible prompt)\n", - "The output must be designed for VIRAL DIGITAL CONTENT\n", - "(short-form video, reels, ads).\n", - "\n", - "[LYRICS REQUIREMENTS]\n", - "Mandatory Inclusions:\n", - "- Business name\n", - "- Region name\n", - "- Promotion subject\n", - "- Promotional expressions including:\n", - "{promotional_expressions[language]}\n", - "\n", - "Content Rules:\n", - "- Lyrics must be emotionally driven, not descriptive listings\n", - "- Selling points must be IMPLIED, not explained\n", - "- Must sound natural when sung\n", - "- Must feel like a lifestyle moment, not an advertisement\n", - "\n", - "Tone & Style:\n", - "- Warm, emotional, and aspirational\n", - "- Trendy, viral-friendly phrasing\n", - "- Calm but memorable hooks\n", - "- Suitable for travel / stay-related content\n", - "\n", - "[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]\n", - "After the lyrics, generate a concise music prompt including:\n", - "Song mood (emotional keywords)\n", - "BPM range\n", - "Recommended genres (max 2)\n", - "Key musical motifs or instruments\n", - "Overall vibe (1 short sentence)\n", - "\n", - "[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]\n", - "ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n", - "no mixed languages\n", - "All names, places, and expressions must be in {language} \n", - "Any violation invalidates the entire output\n", - "\n", - "[OUTPUT RULES – STRICT]\n", - "{timing_rules}\n", - "8–12 lines\n", - "Full verse flow, immersive mood\n", - "\n", - "No explanations\n", - "No headings\n", - "No bullet points\n", - "No analysis\n", - "No extra text\n", - "\n", - "[FAILURE FORMAT]\n", - "If generation is impossible:\n", - "ERROR: Brief reason in English\n", - "\"\"\"\n", - "lyric_prompt_dict = {\n", - " \"prompt_variables\" :\n", - " [\n", - " \"customer_name\",\n", - " \"region\",\n", - " \"detail_region_info\",\n", - " \"marketing_intelligence_summary\",\n", - " \"language\",\n", - " \"promotional_expression_example\",\n", - " \"timing_rules\",\n", - " \n", - " ],\n", - " \"output_format\" : {\n", - " \"format\": {\n", - " \"type\": \"json_schema\",\n", - " \"name\": \"lyric\",\n", - " \"schema\": {\n", - " \"type\":\"object\",\n", - " \"properties\" : {\n", - " \"lyric\" : { \n", - " \"type\" : \"string\"\n", - " }\n", - " },\n", - " \"required\": [\"lyric\"],\n", - " \"additionalProperties\": False,\n", - " },\n", - " \"strict\": True\n", - " }\n", - " }\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "79edd82b-6f4c-43c7-9205-0b970afe06d7", - "metadata": {}, - "outputs": [], - "source": [ - "\n", - "with open(\"./app/utils/prompts/marketing_prompt.txt\", \"w\") as fp:\n", - " fp.write(marketing_prompt)" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "65a5a2a6-06a5-4ee1-a796-406c86aefc20", - "metadata": {}, - "outputs": [], - "source": [ - "with open(\"prompts/summarize_prompt.json\", \"r\") as fp:\n", - " p = json.load(fp)" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "454d920f-e9ed-4fb2-806c-75b8f7033db9", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'prompt_variables': ['report', 'selling_points'],\n", - " 'prompt': '\\n입력 : \\n분석 보고서\\n{report}\\n\\n셀링 포인트\\n{selling_points}\\n\\n위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.\\n\\n조건:\\n각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것\\n태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여\\n- 3 ~ 6단어 이내\\n- 명사 또는 명사형 키워드로 작성\\n- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것\\n- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함\\n- 전체 셀링 포인트 개수는 5~7개로 제한\\n\\n출력 형식:\\n[카테고리명]\\n(태그 키워드)\\n- 한 줄 설명 문구\\n\\n예시: \\n[공간 정체성]\\n(100년 적산가옥 · 시간의 결)\\n- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간\\n\\n[입지 & 희소성]\\n(말랭이마을 · 로컬 히든플레이스)\\n- 관광지가 아닌, 군산을 아는 사람의 선택\\n\\n[프라이버시]\\n(독채 숙소 · 프라이빗 스테이)\\n- 누구의 방해도 없는 완전한 휴식 구조\\n\\n[비주얼 경쟁력]\\n(감성 인테리어 · 자연광 스폿)\\n- 찍는 순간 콘텐츠가 되는 공간 설계\\n\\n[타깃 최적화]\\n(커플 · 소규모 여행)\\n- 둘에게 가장 이상적인 공간 밀도\\n\\n[체류 경험]\\n(아무것도 안 해도 되는 하루)\\n- 일정 없이도 만족되는 하루 루틴\\n\\n[브랜드 포지션]\\n(호텔도 펜션도 아닌 아지트)\\n- 다시 돌아오고 싶은 개인적 장소\\n ',\n", - " 'output_format': {'format': {'type': 'json_schema',\n", - " 'name': 'tags',\n", - " 'schema': {'type': 'object',\n", - " 'properties': {'category': {'type': 'string'},\n", - " 'tag_keywords': {'type': 'string'},\n", - " 'description': {'type': 'string'}},\n", - " 'required': ['category', 'tag_keywords', 'description'],\n", - " 'additionalProperties': False},\n", - " 'strict': True}}}" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "p" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "c46abcda-d6a8-485e-92f1-526fb28c6b53", - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "marketing_prompt_dict = {\n", - " \"model\" : \"gpt-5-mini\",\n", - " \"prompt_variables\" :\n", - " [\n", - " \"customer_name\",\n", - " \"region\",\n", - " \"detail_region_info\"\n", - " ],\n", - " \"output_format\" : {\n", - " \"format\": {\n", - " \"type\": \"json_schema\",\n", - " \"name\": \"report\",\n", - " \"schema\": {\n", - " \"type\" : \"object\",\n", - " \"properties\" : {\n", - " \"report\" : {\n", - " \"type\": \"object\",\n", - " \"properties\" : {\n", - " \"summary\" : {\"type\" : \"string\"},\n", - " \"details\" : {\n", - " \"type\" : \"array\",\n", - " \"items\" : {\n", - " \"type\": \"object\",\n", - " \"properties\" : {\n", - " \"detail_title\" : {\"type\" : \"string\"},\n", - " \"detail_description\" : {\"type\" : \"string\"},\n", - " },\n", - " \"required\": [\"detail_title\", \"detail_description\"],\n", - " \"additionalProperties\": False,\n", - " }\n", - " }\n", - " },\n", - " \"required\" : [\"summary\", \"details\"],\n", - " \"additionalProperties\" : False\n", - " },\n", - " \"selling_points\" : {\n", - " \"type\": \"array\",\n", - " \"items\": {\n", - " \"type\": \"object\",\n", - " \"properties\" : {\n", - " \"category\" : {\"type\" : \"string\"},\n", - " \"keywords\" : {\"type\" : \"string\"},\n", - " \"description\" : {\"type\" : \"string\"}\n", - " },\n", - " \"required\": [\"category\", \"keywords\", \"description\"],\n", - " \"additionalProperties\": False,\n", - " },\n", - " },\n", - " \"tags\" : {\n", - " \"type\": \"array\",\n", - " \"items\": {\n", - " \"type\": \"string\"\n", - " },\n", - " },\n", - " \"contents_advise\" : {\"type\" : \"string\"}\n", - " },\n", - " \"required\": [\"report\", \"selling_points\", \"tags\", \"contents_advise\"],\n", - " \"additionalProperties\": False,\n", - " },\n", - " \"strict\": True\n", - " }\n", - " }\n", - "}\n", - "with open(\"./app/utils/prompts/marketing_prompt.json\", \"w\") as fp:\n", - " json.dump(marketing_prompt_dict, fp, ensure_ascii=False)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "c3867dab-0c4e-46be-ad12-a9c02b5edb68", - "metadata": {}, - "outputs": [], - "source": [ - "lyric_prompt = \"\"\"\n", - "[ROLE]\n", - "You are a content marketing expert, brand strategist, and creative songwriter\n", - "specializing in Korean pension / accommodation businesses.\n", - "You create lyrics strictly based on Brand & Marketing Intelligence analysis\n", - "and optimized for viral short-form video content.\n", - "\n", - "[INPUT]\n", - "Business Name: {customer_name}\n", - "Region: {region}\n", - "Region Details: {detail_region_info}\n", - "Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n", - "Output Language: {language}\n", - "\n", - "[INTERNAL ANALYSIS – DO NOT OUTPUT]\n", - "Internally analyze the following to guide all creative decisions:\n", - "- Core brand identity and positioning\n", - "- Emotional hooks derived from selling points\n", - "- Target audience lifestyle, desires, and travel motivation\n", - "- Regional atmosphere and symbolic imagery\n", - "- How the stay converts into “shareable moments”\n", - "- Which selling points must surface implicitly in lyrics\n", - "\n", - "[LYRICS & MUSIC CREATION TASK]\n", - "Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n", - "- Original promotional lyrics\n", - "- Music attributes for AI music generation (Suno-compatible prompt)\n", - "The output must be designed for VIRAL DIGITAL CONTENT\n", - "(short-form video, reels, ads).\n", - "\n", - "[LYRICS REQUIREMENTS]\n", - "Mandatory Inclusions:\n", - "- Business name\n", - "- Region name\n", - "- Promotion subject\n", - "- Promotional expressions including:\n", - "{promotional_expressions[language]}\n", - "\n", - "Content Rules:\n", - "- Lyrics must be emotionally driven, not descriptive listings\n", - "- Selling points must be IMPLIED, not explained\n", - "- Must sound natural when sung\n", - "- Must feel like a lifestyle moment, not an advertisement\n", - "\n", - "Tone & Style:\n", - "- Warm, emotional, and aspirational\n", - "- Trendy, viral-friendly phrasing\n", - "- Calm but memorable hooks\n", - "- Suitable for travel / stay-related content\n", - "\n", - "[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]\n", - "After the lyrics, generate a concise music prompt including:\n", - "Song mood (emotional keywords)\n", - "BPM range\n", - "Recommended genres (max 2)\n", - "Key musical motifs or instruments\n", - "Overall vibe (1 short sentence)\n", - "\n", - "[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]\n", - "ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n", - "no mixed languages\n", - "All names, places, and expressions must be in {language} \n", - "Any violation invalidates the entire output\n", - "\n", - "[OUTPUT RULES – STRICT]\n", - "{timing_rules}\n", - "8–12 lines\n", - "Full verse flow, immersive mood\n", - "\n", - "No explanations\n", - "No headings\n", - "No bullet points\n", - "No analysis\n", - "No extra text\n", - "\n", - "[FAILURE FORMAT]\n", - "If generation is impossible:\n", - "ERROR: Brief reason in English\n", - "\"\"\"\n", - "with open(\"./app/utils/prompts/lyric_prompt.txt\", \"w\") as fp:\n", - " fp.write(lyric_prompt)" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "5736ca4b-c379-4cae-84a9-534cad9576c7", - "metadata": {}, - "outputs": [], - "source": [ - "lyric_prompt_dict = {\n", - " \"model\" : \"gpt-5-mini\",\n", - " \"prompt_variables\" :\n", - " [\n", - " \"customer_name\",\n", - " \"region\",\n", - " \"detail_region_info\",\n", - " \"marketing_intelligence_summary\",\n", - " \"language\",\n", - " \"promotional_expression_example\",\n", - " \"timing_rules\",\n", - " \n", - " ],\n", - " \"output_format\" : {\n", - " \"format\": {\n", - " \"type\": \"json_schema\",\n", - " \"name\": \"lyric\",\n", - " \"schema\": {\n", - " \"type\":\"object\",\n", - " \"properties\" : {\n", - " \"lyric\" : { \n", - " \"type\" : \"string\"\n", - " }\n", - " },\n", - " \"required\": [\"lyric\"],\n", - " \"additionalProperties\": False,\n", - " },\n", - " \"strict\": True\n", - " }\n", - " }\n", - "}\n", - "with open(\"./app/utils/prompts/lyric_prompt.json\", \"w\") as fp:\n", - " json.dump(lyric_prompt_dict, fp, ensure_ascii=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "430c8914-4e6a-4b53-8903-f454e7ccb8e2", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.8" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/poc/instagram/__init__.py b/poc/instagram/__init__.py new file mode 100644 index 0000000..12504db --- /dev/null +++ b/poc/instagram/__init__.py @@ -0,0 +1,45 @@ +""" +Instagram Graph API POC 패키지 + +단일 클래스로 구현된 Instagram Graph API 클라이언트입니다. + +Example: + ```python + from poc.instagram import InstagramClient + + async with InstagramClient(access_token="YOUR_TOKEN") as client: + media = await client.publish_image( + image_url="https://example.com/image.jpg", + caption="Hello!" + ) + ``` +""" + +from poc.instagram.client import ( + InstagramClient, + ErrorState, + parse_instagram_error, +) +from poc.instagram.sns_schema import ( + Media, + MediaList, + MediaContainer, + APIError, + ErrorResponse, +) + +__all__ = [ + # Client + "InstagramClient", + # Error handling + "ErrorState", + "parse_instagram_error", + # Models + "Media", + "MediaList", + "MediaContainer", + "APIError", + "ErrorResponse", +] + +__version__ = "0.1.0" diff --git a/poc/instagram/client.py b/poc/instagram/client.py new file mode 100644 index 0000000..623239c --- /dev/null +++ b/poc/instagram/client.py @@ -0,0 +1,398 @@ +""" +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 .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 diff --git a/poc/instagram/main.py b/poc/instagram/main.py new file mode 100644 index 0000000..f38150a --- /dev/null +++ b/poc/instagram/main.py @@ -0,0 +1,112 @@ +""" +Instagram Graph API POC - 비디오 업로드 테스트 + +실행 방법: + python -m poc.instagram.main +""" + +import asyncio +import logging +import sys + +from poc.instagram import InstagramClient, ErrorState, parse_instagram_error + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger(__name__) + +# 설정 +ACCESS_TOKEN = "" + +VIDEO_URL2 = "https://f002.backblazeb2.com/file/creatomate-c8xg3hsxdu/9b1a680b-3481-4b22-94d4-a5cfd3e19f95.mp4" + +VIDEO_URL3 = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1c1c-e311-756d-8635-bfe62898f73e/019c1c1d-1a3e-78c9-819a-a9de16f487c7/video/스테이머뭄.mp4" + +VIDEO_URL1 = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1d13-db76-7bfa-849f-02803d9e39fb/019c1d21-b686-7dee-b04e-97c8ffe99c28/video/스테이 머뭄.mp4" + +VIDEO_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1d13-db76-7bfa-849f-02803d9e39fb/019c1d21-b686-7dee-b04e-97c8ffe99c28/video/28aa6541ddd74c348c5aae730a232454.mp4" + +VIDEO_CAPTION = "Test video from Instagram POC #test" + + +async def main(): + """비디오 업로드 POC 실행""" + print("\n" + "=" * 60) + print("Instagram Graph API - 비디오 업로드 POC") + print("=" * 60) + + async with InstagramClient(access_token=ACCESS_TOKEN) as client: + # Step 1: 접속 테스트 + print("\n[Step 1] 접속 테스트") + print("-" * 40) + try: + account_id = await client.get_account_id() + print("[성공] 접속 확인 완료") + print(f" Account ID: {account_id}") + except Exception as e: + error_state, message, extra_info = parse_instagram_error(e) + if error_state == ErrorState.AUTH_ERROR: + print(f"[실패] 인증 실패: {message}") + else: + print(f"[실패] 접속 실패: {message}") + return + + # Step 2: 비디오 업로드 + print("\n[Step 2] 비디오 업로드") + print("-" * 40) + print(f" 비디오 URL: {VIDEO_URL}") + print(f" 캡션: {VIDEO_CAPTION}") + print("\n업로드 중... (비디오 처리에 시간이 걸릴 수 있습니다)") + + try: + media = await client.publish_video( + video_url=VIDEO_URL, + caption=VIDEO_CAPTION, + share_to_feed=True, + ) + print("\n[성공] 비디오 업로드 완료!") + print(f" 미디어 ID: {media.id}") + print(f" 링크: {media.permalink}") + except Exception as e: + error_state, message, extra_info = parse_instagram_error(e) + if error_state == ErrorState.RATE_LIMIT: + retry_after = extra_info.get("retry_after", 60) + print(f"\n[실패] Rate Limit: {message} (재시도: {retry_after}초)") + elif error_state == ErrorState.CONTAINER_TIMEOUT: + print(f"\n[실패] 타임아웃: {message}") + elif error_state == ErrorState.CONTAINER_ERROR: + status = extra_info.get("status", "UNKNOWN") + print(f"\n[실패] 컨테이너 에러: {message} (상태: {status})") + else: + print(f"\n[실패] 업로드 실패: {message}") + return + + # Step 3: 업로드 확인 + print("\n[Step 3] 업로드 확인") + print("-" * 40) + try: + verified_media = await client.get_media(media.id) + print("[성공] 업로드 확인 완료!") + print(f" 미디어 ID: {verified_media.id}") + print(f" 타입: {verified_media.media_type}") + print(f" URL: {verified_media.media_url}") + print(f" 퍼머링크: {verified_media.permalink}") + print(f" 게시일: {verified_media.timestamp}") + if verified_media.caption: + print(f" 캡션: {verified_media.caption}") + except Exception as e: + error_state, message, _ = parse_instagram_error(e) + print(f"[실패] 확인 실패: {message}") + return + + print("\n" + "=" * 60) + print("모든 단계 완료!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/poc/instagram/sns_schema.py b/poc/instagram/sns_schema.py new file mode 100644 index 0000000..fe7bb21 --- /dev/null +++ b/poc/instagram/sns_schema.py @@ -0,0 +1,75 @@ +""" +Instagram Graph API Pydantic 모델 + +API 응답 데이터를 위한 Pydantic 모델 정의입니다. +""" + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +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 MediaList(BaseModel): + """미디어 목록 응답""" + + data: list[Media] = Field(default_factory=list) + paging: Optional[dict[str, Any]] = None + + @property + def next_cursor(self) -> Optional[str]: + """다음 페이지 커서""" + if self.paging and "cursors" in self.paging: + return self.paging["cursors"].get("after") + return 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