From bea0fa0684ea429a8bc54e9dd5b51d2b90cf61d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 12 Jun 2026 14:02:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/social/schemas/upload_schema.py | 3 ++ app/social/services/upload_service.py | 38 +++++++++++++++++-- app/utils/prompts/prompts.py | 24 ++---------- .../prompts/templates/subtitle_prompt.txt | 5 +++ 4 files changed, 47 insertions(+), 23 deletions(-) diff --git a/app/social/schemas/upload_schema.py b/app/social/schemas/upload_schema.py index d095262..d33e167 100644 --- a/app/social/schemas/upload_schema.py +++ b/app/social/schemas/upload_schema.py @@ -56,6 +56,8 @@ class SocialUploadResponse(BaseModel): platform: str = Field(..., description="플랫폼명") status: str = Field(..., description="업로드 상태") message: str = Field(..., description="응답 메시지") + scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)") + model_config = ConfigDict( json_schema_extra={ @@ -65,6 +67,7 @@ class SocialUploadResponse(BaseModel): "platform": "youtube", "status": "pending", "message": "업로드 요청이 접수되었습니다.", + "scheduled_at": "2026-02-02T15:00:00", } } ) diff --git a/app/social/services/upload_service.py b/app/social/services/upload_service.py index dec2d88..a2da831 100644 --- a/app/social/services/upload_service.py +++ b/app/social/services/upload_service.py @@ -10,7 +10,7 @@ from datetime import datetime from typing import Optional from fastapi import BackgroundTasks, HTTPException, status -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from config import TIMEZONE @@ -90,12 +90,19 @@ class SocialUploadService: ) 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( select(SocialUpload).where( SocialUpload.video_id == body.video_id, SocialUpload.social_account_id == account.id, 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() @@ -112,6 +119,32 @@ class SocialUploadService: 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. 업로드 순번 계산 max_seq_result = await session.execute( select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where( @@ -153,7 +186,6 @@ class SocialUploadService: ) # 6. 즉시 업로드이면 백그라운드 태스크 등록 - now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None) is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive if not is_scheduled: background_tasks.add_task(process_social_upload, social_upload.id) diff --git a/app/utils/prompts/prompts.py b/app/utils/prompts/prompts.py index bbbd2e7..a26ee95 100644 --- a/app/utils/prompts/prompts.py +++ b/app/utils/prompts/prompts.py @@ -1,6 +1,4 @@ -import os import gspread -from pathlib import Path from pydantic import BaseModel from google.oauth2.service_account import Credentials from config import prompt_settings @@ -36,24 +34,15 @@ class Prompt(): prompt_input_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.prompt_input_class = prompt_input_class self.prompt_output_class = prompt_output_class - if template_file: - 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 + self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name) def _reload_prompt(self): - if self._template_file: - self.prompt_template = Path(self._template_file).read_text(encoding="utf-8") - else: - _sheet_cache.pop(self.sheet_name, None) - self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name) + _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: verified_input = self.prompt_input_class(**input_data) @@ -88,17 +77,12 @@ image_autotag_prompt = Prompt( prompt_output_class=ImageTagPromptOutput, ) -_SUBTITLE_TEMPLATE_PATH = str(Path(__file__).parent / "templates" / "subtitle_prompt.txt") - - @lru_cache() def create_dynamic_subtitle_prompt(length: int) -> Prompt: return Prompt( sheet_name="subtitle", prompt_input_class=SubtitlePromptInput, prompt_output_class=SubtitlePromptOutput[length], - template_file=_SUBTITLE_TEMPLATE_PATH, - prompt_model=os.getenv("SUBTITLE_PROMPT_MODEL", "gpt-4o-mini"), ) diff --git a/app/utils/prompts/templates/subtitle_prompt.txt b/app/utils/prompts/templates/subtitle_prompt.txt index ebde93a..5b23d0d 100644 --- a/app/utils/prompts/templates/subtitle_prompt.txt +++ b/app/utils/prompts/templates/subtitle_prompt.txt @@ -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. 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. +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. ---