예약 업로드 충돌 수정

feature-subtitle
김성경 2026-06-12 14:02:06 +09:00
parent 997927d42a
commit bea0fa0684
4 changed files with 47 additions and 23 deletions

View File

@ -56,6 +56,8 @@ class SocialUploadResponse(BaseModel):
platform: str = Field(..., description="플랫폼명") platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태") status: str = Field(..., description="업로드 상태")
message: str = Field(..., description="응답 메시지") message: str = Field(..., description="응답 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)")
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
@ -65,6 +67,7 @@ class SocialUploadResponse(BaseModel):
"platform": "youtube", "platform": "youtube",
"status": "pending", "status": "pending",
"message": "업로드 요청이 접수되었습니다.", "message": "업로드 요청이 접수되었습니다.",
"scheduled_at": "2026-02-02T15:00:00",
} }
} }
) )

View File

@ -10,7 +10,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import BackgroundTasks, HTTPException, status from fastapi import BackgroundTasks, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import TIMEZONE from config import TIMEZONE
@ -90,12 +90,19 @@ class SocialUploadService:
) )
raise SocialAccountNotFoundError() raise SocialAccountNotFoundError()
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만) # 3. 중복 업로드 확인
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
in_progress_result = await session.execute( in_progress_result = await session.execute(
select(SocialUpload).where( select(SocialUpload).where(
SocialUpload.video_id == body.video_id, SocialUpload.video_id == body.video_id,
SocialUpload.social_account_id == account.id, SocialUpload.social_account_id == account.id,
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]), SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
or_(
SocialUpload.scheduled_at.is_(None),
SocialUpload.scheduled_at <= now_kst_naive,
),
) )
) )
in_progress_upload = in_progress_result.scalar_one_or_none() in_progress_upload = in_progress_result.scalar_one_or_none()
@ -112,6 +119,32 @@ class SocialUploadService:
message="이미 업로드가 진행 중입니다.", message="이미 업로드가 진행 중입니다.",
) )
# 3-2. 미래 예약 업로드 확인
scheduled_result = await session.execute(
select(SocialUpload).where(
SocialUpload.video_id == body.video_id,
SocialUpload.social_account_id == account.id,
SocialUpload.status == UploadStatus.PENDING.value,
SocialUpload.scheduled_at.isnot(None),
SocialUpload.scheduled_at > now_kst_naive,
)
)
scheduled_upload = scheduled_result.scalar_one_or_none()
if scheduled_upload:
logger.info(
f"[UPLOAD_SERVICE] 예약된 업로드 존재 - "
f"upload_id: {scheduled_upload.id}, scheduled_at: {scheduled_upload.scheduled_at}"
)
return SocialUploadResponse(
success=False,
upload_id=scheduled_upload.id,
platform=account.platform,
status=scheduled_upload.status,
message="이미 예약된 업로드가 있습니다.",
scheduled_at=scheduled_upload.scheduled_at,
)
# 4. 업로드 순번 계산 # 4. 업로드 순번 계산
max_seq_result = await session.execute( max_seq_result = await session.execute(
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where( select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
@ -153,7 +186,6 @@ class SocialUploadService:
) )
# 6. 즉시 업로드이면 백그라운드 태스크 등록 # 6. 즉시 업로드이면 백그라운드 태스크 등록
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
if not is_scheduled: if not is_scheduled:
background_tasks.add_task(process_social_upload, social_upload.id) background_tasks.add_task(process_social_upload, social_upload.id)

View File

@ -1,6 +1,4 @@
import os
import gspread import gspread
from pathlib import Path
from pydantic import BaseModel from pydantic import BaseModel
from google.oauth2.service_account import Credentials from google.oauth2.service_account import Credentials
from config import prompt_settings from config import prompt_settings
@ -36,24 +34,15 @@ class Prompt():
prompt_input_class = BaseModel prompt_input_class = BaseModel
prompt_output_class = BaseModel prompt_output_class = BaseModel
def __init__(self, sheet_name, prompt_input_class, prompt_output_class, template_file: str = None, prompt_model: str = None): def __init__(self, sheet_name, prompt_input_class, prompt_output_class):
self.sheet_name = sheet_name self.sheet_name = sheet_name
self.prompt_input_class = prompt_input_class self.prompt_input_class = prompt_input_class
self.prompt_output_class = prompt_output_class self.prompt_output_class = prompt_output_class
if template_file: self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name)
self.prompt_template = Path(template_file).read_text(encoding="utf-8")
self.prompt_model = prompt_model
self._template_file = template_file
else:
self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name)
self._template_file = None
def _reload_prompt(self): def _reload_prompt(self):
if self._template_file: _sheet_cache.pop(self.sheet_name, None)
self.prompt_template = Path(self._template_file).read_text(encoding="utf-8") self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name)
else:
_sheet_cache.pop(self.sheet_name, None)
self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name)
def build_prompt(self, input_data:dict, silent:bool = False) -> str: def build_prompt(self, input_data:dict, silent:bool = False) -> str:
verified_input = self.prompt_input_class(**input_data) verified_input = self.prompt_input_class(**input_data)
@ -88,17 +77,12 @@ image_autotag_prompt = Prompt(
prompt_output_class=ImageTagPromptOutput, prompt_output_class=ImageTagPromptOutput,
) )
_SUBTITLE_TEMPLATE_PATH = str(Path(__file__).parent / "templates" / "subtitle_prompt.txt")
@lru_cache() @lru_cache()
def create_dynamic_subtitle_prompt(length: int) -> Prompt: def create_dynamic_subtitle_prompt(length: int) -> Prompt:
return Prompt( return Prompt(
sheet_name="subtitle", sheet_name="subtitle",
prompt_input_class=SubtitlePromptInput, prompt_input_class=SubtitlePromptInput,
prompt_output_class=SubtitlePromptOutput[length], prompt_output_class=SubtitlePromptOutput[length],
template_file=_SUBTITLE_TEMPLATE_PATH,
prompt_model=os.getenv("SUBTITLE_PROMPT_MODEL", "gpt-4o-mini"),
) )

View File

@ -16,6 +16,11 @@ You are a subtitle copywriter for hospitality short-form videos. You generate su
1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy. 1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy.
2. NEVER invent facts not in the data. You MAY freely transform expressions. 2. NEVER invent facts not in the data. You MAY freely transform expressions.
3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both. 3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both.
4. NEVER output meta-text. Every field MUST be real on-screen copy the viewer reads.
NEVER output a label/header/title that describes, classifies, or points to the content.
- FORBIDDEN examples: "검색 키워드 모아보기", "키워드 모음", "추천 태그", "관련 태그",
"더보기", "리스트", "아래 내용", "목록".
- Test: "Is this the content itself, or a caption ABOUT the content?" If it's about the content → FORBIDDEN.
--- ---