diff --git a/app/social/api/routers/v1/internal.py b/app/social/api/routers/v1/internal.py index e079f9d..db5d26c 100644 --- a/app/social/api/routers/v1/internal.py +++ b/app/social/api/routers/v1/internal.py @@ -17,14 +17,6 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/internal/social", tags=["Internal"]) -def _verify_secret(x_internal_secret: str = Header(...)) -> None: - if x_internal_secret != internal_settings.INTERNAL_SECRET_KEY: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid internal secret", - ) - - @router.post( "/upload/{upload_id}", summary="[내부] 예약 업로드 실행", @@ -35,7 +27,11 @@ async def trigger_scheduled_upload( background_tasks: BackgroundTasks, x_internal_secret: str = Header(...), ) -> dict: - _verify_secret(x_internal_secret) + if x_internal_secret != internal_settings.INTERNAL_SECRET_KEY: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid internal secret", + ) logger.info(f"[INTERNAL] 예약 업로드 실행 - upload_id: {upload_id}") background_tasks.add_task(process_social_upload, upload_id) diff --git a/app/social/api/routers/v1/upload.py b/app/social/api/routers/v1/upload.py index ca85740..a6272d0 100644 --- a/app/social/api/routers/v1/upload.py +++ b/app/social/api/routers/v1/upload.py @@ -5,6 +5,7 @@ """ import logging +from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, Query @@ -178,7 +179,7 @@ async def upload_to_social( # 6. 백그라운드 태스크 등록 (즉시 업로드 or 예약 없을 때만) from app.utils.timezone import now as utcnow - is_scheduled = body.scheduled_at and body.scheduled_at > utcnow() + is_scheduled = body.scheduled_at and body.scheduled_at > utcnow().replace(tzinfo=None) if not is_scheduled: background_tasks.add_task(process_social_upload, social_upload.id) @@ -246,42 +247,81 @@ async def get_upload_status( "/history", response_model=SocialUploadHistoryResponse, summary="업로드 이력 조회", - description="사용자의 소셜 미디어 업로드 이력을 조회합니다.", + description=""" +사용자의 소셜 미디어 업로드 이력을 조회합니다. + +## tab 파라미터 +- `all`: 전체 (기본값) +- `completed`: 완료된 업로드 +- `scheduled`: 예약 업로드 (pending + scheduled_at 있음) +- `failed`: 실패한 업로드 +""", ) async def get_upload_history( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), + tab: str = Query("all", description="탭 필터 (all/completed/scheduled/failed)"), platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"), - status: Optional[UploadStatus] = Query(None, description="상태 필터"), + year: Optional[int] = Query(None, description="조회 연도 (없으면 현재 연도)"), + month: Optional[int] = Query(None, ge=1, le=12, description="조회 월 (없으면 현재 월)"), page: int = Query(1, ge=1, description="페이지 번호"), size: int = Query(20, ge=1, le=100, description="페이지 크기"), ) -> SocialUploadHistoryResponse: """ 업로드 이력 조회 """ + now = datetime.now(timezone.utc) + target_year = year or now.year + target_month = month or now.month + logger.info( f"[UPLOAD_API] 이력 조회 - " - f"user_uuid: {current_user.user_uuid}, page: {page}, size: {size}" + f"user_uuid: {current_user.user_uuid}, tab: {tab}, " + f"year: {target_year}, month: {target_month}, page: {page}, size: {size}" ) - # 기본 쿼리 + # 월 범위 계산 + from calendar import monthrange + last_day = monthrange(target_year, target_month)[1] + month_start = datetime(target_year, target_month, 1, 0, 0, 0) + month_end = datetime(target_year, target_month, last_day, 23, 59, 59) + + # 기본 쿼리 (cancelled 제외) query = select(SocialUpload).where( - SocialUpload.user_uuid == current_user.user_uuid + SocialUpload.user_uuid == current_user.user_uuid, + SocialUpload.created_at >= month_start, + SocialUpload.created_at <= month_end, + SocialUpload.status != UploadStatus.CANCELLED.value, ) - count_query = select(func.count(SocialUpload.id)).where( - SocialUpload.user_uuid == current_user.user_uuid + SocialUpload.user_uuid == current_user.user_uuid, + SocialUpload.created_at >= month_start, + SocialUpload.created_at <= month_end, + SocialUpload.status != UploadStatus.CANCELLED.value, ) - # 필터 적용 + # 탭 필터 적용 + if tab == "completed": + query = query.where(SocialUpload.status == UploadStatus.COMPLETED.value) + count_query = count_query.where(SocialUpload.status == UploadStatus.COMPLETED.value) + elif tab == "scheduled": + query = query.where( + SocialUpload.status == UploadStatus.PENDING.value, + SocialUpload.scheduled_at.isnot(None), + ) + count_query = count_query.where( + SocialUpload.status == UploadStatus.PENDING.value, + SocialUpload.scheduled_at.isnot(None), + ) + elif tab == "failed": + query = query.where(SocialUpload.status == UploadStatus.FAILED.value) + count_query = count_query.where(SocialUpload.status == UploadStatus.FAILED.value) + + # 플랫폼 필터 적용 if platform: query = query.where(SocialUpload.platform == platform.value) count_query = count_query.where(SocialUpload.platform == platform.value) - if status: - query = query.where(SocialUpload.status == status.value) - count_query = count_query.where(SocialUpload.status == status.value) - # 총 개수 조회 total_result = await session.execute(count_query) total = total_result.scalar() or 0 @@ -306,6 +346,8 @@ async def get_upload_history( status=upload.status, title=upload.title, platform_url=upload.platform_url, + error_message=upload.error_message, + scheduled_at=upload.scheduled_at, created_at=upload.created_at, uploaded_at=upload.uploaded_at, ) diff --git a/app/social/schemas.py b/app/social/schemas.py index 580a1cb..783ac6a 100644 --- a/app/social/schemas.py +++ b/app/social/schemas.py @@ -203,6 +203,7 @@ class SocialUploadStatusResponse(BaseModel): platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") error_message: Optional[str] = Field(None, description="에러 메시지") retry_count: int = Field(default=0, description="재시도 횟수") + scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간 (있으면 예약 업로드)") created_at: datetime = Field(..., description="생성 일시") uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") @@ -240,6 +241,8 @@ class SocialUploadHistoryItem(BaseModel): status: str = Field(..., description="업로드 상태") title: str = Field(..., description="영상 제목") platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") + error_message: Optional[str] = Field(None, description="에러 메시지") + scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간") created_at: datetime = Field(..., description="생성 일시") uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") diff --git a/docs/database-schema/migration_2026-03-05_social_upload_scheduled_at.sql b/docs/database-schema/migration_2026-03-05_social_upload_scheduled_at.sql new file mode 100644 index 0000000..8e9a1d0 --- /dev/null +++ b/docs/database-schema/migration_2026-03-05_social_upload_scheduled_at.sql @@ -0,0 +1,9 @@ +-- ============================================================ +-- Migration: social_upload 테이블에 scheduled_at 컬럼 추가 +-- Date: 2026-03-05 +-- Description: SNS 예약 업로드 스케줄러를 위한 예약 게시 시간 컬럼 추가 +-- ============================================================ + +ALTER TABLE social_upload + ADD COLUMN scheduled_at DATETIME NULL COMMENT '예약 게시 시간 (스케줄러가 이 시간 이후에 업로드 실행)' + AFTER privacy_status;