예약 업로드 충돌 수정
parent
997927d42a
commit
bea0fa0684
|
|
@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,22 +34,13 @@ 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 = 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.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:
|
|
||||||
self.prompt_template = Path(self._template_file).read_text(encoding="utf-8")
|
|
||||||
else:
|
|
||||||
_sheet_cache.pop(self.sheet_name, None)
|
_sheet_cache.pop(self.sheet_name, None)
|
||||||
self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name)
|
self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name)
|
||||||
|
|
||||||
|
|
@ -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"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue