Compare commits

...

2 Commits

Author SHA1 Message Date
hbyang 6e70c416b8 스케줄 작업 완료 . 2026-03-05 15:24:06 +09:00
hbyang 7a887153ab 내부 youtube 업로드 endpoint 적용 . 2026-03-03 16:16:23 +09:00
7 changed files with 137 additions and 16 deletions

View File

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

View File

@ -4,7 +4,8 @@
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다. 소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
""" """
import logging, json import logging
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Query from fastapi import APIRouter, BackgroundTasks, Depends, Query
@ -158,6 +159,7 @@ async def upload_to_social(
description=body.description, description=body.description,
tags=body.tags, tags=body.tags,
privacy_status=body.privacy_status.value, privacy_status=body.privacy_status.value,
scheduled_at=body.scheduled_at,
platform_options={ platform_options={
**(body.platform_options or {}), **(body.platform_options or {}),
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None, "scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
@ -175,15 +177,19 @@ async def upload_to_social(
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}" f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
) )
# 6. 백그라운드 태스크 등록 # 6. 백그라운드 태스크 등록 (즉시 업로드 or 예약 없을 때만)
background_tasks.add_task(process_social_upload, social_upload.id) from app.utils.timezone import now as 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)
message = "예약 업로드가 등록되었습니다." if is_scheduled else "업로드 요청이 접수되었습니다."
return SocialUploadResponse( return SocialUploadResponse(
success=True, success=True,
upload_id=social_upload.id, upload_id=social_upload.id,
platform=account.platform, platform=account.platform,
status=social_upload.status, status=social_upload.status,
message="업로드 요청이 접수되었습니다.", message=message,
) )
@ -241,42 +247,81 @@ async def get_upload_status(
"/history", "/history",
response_model=SocialUploadHistoryResponse, response_model=SocialUploadHistoryResponse,
summary="업로드 이력 조회", summary="업로드 이력 조회",
description="사용자의 소셜 미디어 업로드 이력을 조회합니다.", description="""
사용자의 소셜 미디어 업로드 이력을 조회합니다.
## tab 파라미터
- `all`: 전체 (기본값)
- `completed`: 완료된 업로드
- `scheduled`: 예약 업로드 (pending + scheduled_at 있음)
- `failed`: 실패한 업로드
""",
) )
async def get_upload_history( async def get_upload_history(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
tab: str = Query("all", description="탭 필터 (all/completed/scheduled/failed)"),
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"), platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
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="페이지 번호"), page: int = Query(1, ge=1, description="페이지 번호"),
size: int = Query(20, ge=1, le=100, description="페이지 크기"), size: int = Query(20, ge=1, le=100, description="페이지 크기"),
) -> SocialUploadHistoryResponse: ) -> SocialUploadHistoryResponse:
""" """
업로드 이력 조회 업로드 이력 조회
""" """
now = datetime.now(timezone.utc)
target_year = year or now.year
target_month = month or now.month
logger.info( logger.info(
f"[UPLOAD_API] 이력 조회 - " 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( 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( 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: if platform:
query = query.where(SocialUpload.platform == platform.value) query = query.where(SocialUpload.platform == platform.value)
count_query = count_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_result = await session.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
@ -301,6 +346,8 @@ async def get_upload_history(
status=upload.status, status=upload.status,
title=upload.title, title=upload.title,
platform_url=upload.platform_url, platform_url=upload.platform_url,
error_message=upload.error_message,
scheduled_at=upload.scheduled_at,
created_at=upload.created_at, created_at=upload.created_at,
uploaded_at=upload.uploaded_at, uploaded_at=upload.uploaded_at,
) )

View File

@ -190,6 +190,15 @@ class SocialUpload(Base):
comment="플랫폼별 추가 옵션 (JSON)", comment="플랫폼별 추가 옵션 (JSON)",
) )
# ==========================================================================
# 예약 게시 시간
# ==========================================================================
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="예약 게시 시간 (스케줄러가 이 시간 이후에 업로드 실행)",
)
# ========================================================================== # ==========================================================================
# 에러 정보 # 에러 정보
# ========================================================================== # ==========================================================================

View File

@ -203,6 +203,7 @@ class SocialUploadStatusResponse(BaseModel):
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지") error_message: Optional[str] = Field(None, description="에러 메시지")
retry_count: int = Field(default=0, description="재시도 횟수") retry_count: int = Field(default=0, description="재시도 횟수")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간 (있으면 예약 업로드)")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
@ -240,6 +241,8 @@ class SocialUploadHistoryItem(BaseModel):
status: str = Field(..., description="업로드 상태") status: str = Field(..., description="업로드 상태")
title: str = Field(..., description="영상 제목") title: str = Field(..., description="영상 제목")
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")

View File

@ -566,6 +566,17 @@ class SocialOAuthSettings(BaseSettings):
model_config = _base_config model_config = _base_config
class InternalSettings(BaseSettings):
"""내부 서버 간 통신 설정"""
INTERNAL_SECRET_KEY: str = Field(
default="change-me-internal-secret-key",
description="스케줄러 서버 → 백엔드 내부 API 인증 키",
)
model_config = _base_config
class SocialUploadSettings(BaseSettings): class SocialUploadSettings(BaseSettings):
"""소셜 미디어 업로드 설정 """소셜 미디어 업로드 설정
@ -613,4 +624,5 @@ kakao_settings = KakaoSettings()
jwt_settings = JWTSettings() jwt_settings = JWTSettings()
recovery_settings = RecoverySettings() recovery_settings = RecoverySettings()
social_oauth_settings = SocialOAuthSettings() social_oauth_settings = SocialOAuthSettings()
internal_settings = InternalSettings()
social_upload_settings = SocialUploadSettings() social_upload_settings = SocialUploadSettings()

View File

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

View File

@ -22,6 +22,7 @@ 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.oauth import router as social_oauth_router
from app.social.api.routers.v1.upload import router as social_upload_router from app.social.api.routers.v1.upload import router as social_upload_router
from app.social.api.routers.v1.seo import router as social_seo_router from app.social.api.routers.v1.seo import router as social_seo_router
from app.social.api.routers.v1.internal import router as social_internal_router
from app.utils.cors import CustomCORSMiddleware from app.utils.cors import CustomCORSMiddleware
from config import prj_settings from config import prj_settings
@ -362,6 +363,7 @@ app.include_router(archive_router) # Archive API 라우터 추가
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가 app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가 app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가 app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가
app.include_router(social_internal_router) # 내부 스케줄러 전용 라우터
app.include_router(sns_router) # SNS API 라우터 추가 app.include_router(sns_router) # SNS API 라우터 추가
# DEBUG 모드에서만 테스트 라우터 등록 # DEBUG 모드에서만 테스트 라우터 등록