데모버전 안정화
parent
7038faaf74
commit
1e16e0e3eb
|
|
@ -36,3 +36,5 @@ static/
|
|||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
|
||||
.env*
|
||||
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.home.models import Image, Project
|
||||
from app.home.models import Image, Project, UserProject
|
||||
|
||||
|
||||
class ProjectAdmin(ModelView, model=Project):
|
||||
|
|
@ -100,3 +100,44 @@ class ImageAdmin(ModelView, model=Image):
|
|||
"img_url": "이미지 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
||||
|
||||
class UserProjectAdmin(ModelView, model=UserProject):
|
||||
name = "사용자-프로젝트"
|
||||
name_plural = "사용자-프로젝트 목록"
|
||||
icon = "fa-solid fa-link"
|
||||
category = "프로젝트 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
"user",
|
||||
"project",
|
||||
]
|
||||
|
||||
column_searchable_list = [
|
||||
UserProject.user_id,
|
||||
UserProject.project_id,
|
||||
]
|
||||
|
||||
column_sortable_list = [
|
||||
UserProject.id,
|
||||
UserProject.user_id,
|
||||
UserProject.project_id,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_id": "사용자 ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"user": "사용자",
|
||||
"project": "프로젝트",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from app.utils.chatgpt_prompt import ChatgptService
|
|||
from app.utils.common import generate_task_id
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
|
||||
from app.utils.prompts.prompts import marketing_prompt
|
||||
from app.utils.prompts.prompts import marketing_prompt
|
||||
from config import MEDIA_ROOT
|
||||
|
||||
# 로거 설정
|
||||
|
|
@ -119,14 +119,18 @@ async def crawling(request_body: CrawlingRequest):
|
|||
await scraper.scrap()
|
||||
except GraphQLException as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)")
|
||||
logger.error(
|
||||
f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)")
|
||||
logger.error(
|
||||
f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.exception("[crawling] Step 1 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
|
|
@ -135,7 +139,9 @@ async def crawling(request_body: CrawlingRequest):
|
|||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
|
||||
logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
|
||||
logger.info(
|
||||
f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# ========== Step 2: 정보 가공 ==========
|
||||
step2_start = time.perf_counter()
|
||||
|
|
@ -156,7 +162,9 @@ async def crawling(request_body: CrawlingRequest):
|
|||
)
|
||||
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
|
||||
logger.info(
|
||||
f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
||||
step3_start = time.perf_counter()
|
||||
|
|
@ -167,14 +175,16 @@ async def crawling(request_body: CrawlingRequest):
|
|||
step3_1_start = time.perf_counter()
|
||||
chatgpt_service = ChatgptService()
|
||||
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
|
||||
logger.debug(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)")
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# Step 3-2: 프롬프트 생성
|
||||
# step3_2_start = time.perf_counter()
|
||||
input_marketing_data = {
|
||||
"customer_name" : customer_name,
|
||||
"region" : region,
|
||||
"detail_region_info" : road_address or ""
|
||||
"customer_name": customer_name,
|
||||
"region": region,
|
||||
"detail_region_info": road_address or "",
|
||||
}
|
||||
# prompt = chatgpt_service.build_market_analysis_prompt()
|
||||
# prompt1 = marketing_prompt.build_prompt(input_marketing_data)
|
||||
|
|
@ -182,47 +192,64 @@ async def crawling(request_body: CrawlingRequest):
|
|||
|
||||
# Step 3-3: GPT API 호출
|
||||
step3_3_start = time.perf_counter()
|
||||
structured_report = await chatgpt_service.generate_structured_output(marketing_prompt, input_marketing_data)
|
||||
structured_report = await chatgpt_service.generate_structured_output(
|
||||
marketing_prompt, input_marketing_data
|
||||
)
|
||||
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||
logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)")
|
||||
logger.debug(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)")
|
||||
|
||||
logger.info(
|
||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
|
||||
step3_4_start = time.perf_counter()
|
||||
logger.debug(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}")
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}"
|
||||
)
|
||||
|
||||
# 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중
|
||||
# parsed = await chatgpt_service.parse_marketing_analysis(
|
||||
# structured_report, facility_info=scraper.facility_info
|
||||
# )
|
||||
|
||||
|
||||
# marketing_analysis = MarketingAnalysis(**parsed)
|
||||
|
||||
marketing_analysis = MarketingAnalysis(
|
||||
report=structured_report["report"],
|
||||
tags=structured_report["tags"],
|
||||
facilities = list([sp['keywords'] for sp in structured_report["selling_points"]])# [json.dumps(structured_report["selling_points"])] # 나중에 Selling Points로 변수와 데이터구조 변경할 것
|
||||
facilities=list(
|
||||
[sp["keywords"] for sp in structured_report["selling_points"]]
|
||||
), # [json.dumps(structured_report["selling_points"])] # 나중에 Selling Points로 변수와 데이터구조 변경할 것
|
||||
)
|
||||
# Selling Points 구조
|
||||
# print(sp['category'])
|
||||
# print(sp['keywords'])
|
||||
# print(sp['description'])
|
||||
# Selling Points 구조
|
||||
# print(sp['category'])
|
||||
# print(sp['keywords'])
|
||||
# print(sp['description'])
|
||||
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
|
||||
logger.debug(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)")
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
|
||||
logger.info(
|
||||
f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)")
|
||||
logger.error(
|
||||
f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.exception("[crawling] Step 3 상세 오류:")
|
||||
# GPT 실패 시에도 크롤링 결과는 반환
|
||||
marketing_analysis = None
|
||||
else:
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.warning(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
|
||||
logger.warning(
|
||||
f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# ========== 완료 ==========
|
||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||
|
|
@ -231,16 +258,16 @@ async def crawling(request_body: CrawlingRequest):
|
|||
logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
|
||||
if scraper.base_info:
|
||||
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
|
||||
if 'step3_elapsed' in locals():
|
||||
if "step3_elapsed" in locals():
|
||||
logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
|
||||
if 'step3_3_elapsed' in locals():
|
||||
if "step3_3_elapsed" in locals():
|
||||
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||
|
||||
return {
|
||||
"image_list": scraper.image_link_list,
|
||||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||
"processed_info": processed_info,
|
||||
"marketing_analysis": marketing_analysis
|
||||
"marketing_analysis": marketing_analysis,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -598,6 +625,15 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||
},
|
||||
tags=["Image-Blob"],
|
||||
openapi_extra={
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"encoding": {"files": {"contentType": "application/octet-stream"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def upload_images_blob(
|
||||
images_json: Optional[str] = Form(
|
||||
|
|
@ -606,7 +642,8 @@ async def upload_images_blob(
|
|||
examples=[IMAGES_JSON_EXAMPLE],
|
||||
),
|
||||
files: Optional[list[UploadFile]] = File(
|
||||
default=None, description="이미지 바이너리 파일 목록"
|
||||
default=None,
|
||||
description="이미지 바이너리 파일 목록",
|
||||
),
|
||||
) -> ImageUploadResponse:
|
||||
"""이미지 업로드 (URL + Azure Blob Storage)
|
||||
|
|
@ -674,9 +711,11 @@ async def upload_images_blob(
|
|||
)
|
||||
|
||||
stage1_time = time.perf_counter()
|
||||
logger.info(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
||||
f"files: {len(valid_files_data)}, "
|
||||
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms")
|
||||
logger.info(
|
||||
f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
||||
f"files: {len(valid_files_data)}, "
|
||||
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
|
||||
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
|
||||
|
|
@ -695,8 +734,10 @@ async def upload_images_blob(
|
|||
)
|
||||
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
||||
|
||||
logger.debug(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: "
|
||||
f"{filename} ({len(file_content)} bytes)")
|
||||
logger.debug(
|
||||
f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: "
|
||||
f"{filename} ({len(file_content)} bytes)"
|
||||
)
|
||||
|
||||
# Azure Blob Storage에 직접 업로드
|
||||
upload_success = await uploader.upload_image_bytes(file_content, filename)
|
||||
|
|
@ -705,15 +746,21 @@ async def upload_images_blob(
|
|||
blob_url = uploader.public_url
|
||||
blob_upload_results.append((original_name, blob_url))
|
||||
img_order += 1
|
||||
logger.debug(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS")
|
||||
logger.debug(
|
||||
f"[upload_images_blob] File {idx + 1}/{total_files} SUCCESS"
|
||||
)
|
||||
else:
|
||||
skipped_files.append(filename)
|
||||
logger.warning(f"[upload_images_blob] File {idx+1}/{total_files} FAILED")
|
||||
logger.warning(
|
||||
f"[upload_images_blob] File {idx + 1}/{total_files} FAILED"
|
||||
)
|
||||
|
||||
stage2_time = time.perf_counter()
|
||||
logger.info(f"[upload_images_blob] Stage 2 done - blob uploads: "
|
||||
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
|
||||
f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
|
||||
logger.info(
|
||||
f"[upload_images_blob] Stage 2 done - blob uploads: "
|
||||
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
|
||||
f"elapsed: {(stage2_time - stage1_time) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
|
||||
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
|
||||
|
|
@ -724,9 +771,7 @@ async def upload_images_blob(
|
|||
async with AsyncSessionLocal() as session:
|
||||
# URL 이미지 저장
|
||||
for url_item in url_images:
|
||||
img_name = (
|
||||
url_item.name or _extract_image_name(url_item.url, img_order)
|
||||
)
|
||||
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
||||
|
||||
image = Image(
|
||||
task_id=task_id,
|
||||
|
|
@ -772,9 +817,11 @@ async def upload_images_blob(
|
|||
|
||||
await session.commit()
|
||||
stage3_time = time.perf_counter()
|
||||
logger.info(f"[upload_images_blob] Stage 3 done - "
|
||||
f"saved: {len(result_images)}, "
|
||||
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
|
||||
logger.info(
|
||||
f"[upload_images_blob] Stage 3 done - "
|
||||
f"saved: {len(result_images)}, "
|
||||
f"elapsed: {(stage3_time - stage2_time) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
|
||||
|
|
@ -784,8 +831,10 @@ async def upload_images_blob(
|
|||
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
|
||||
logger.error(
|
||||
f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||
)
|
||||
logger.exception("[upload_images_blob] Stage 3 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -796,8 +845,10 @@ async def upload_images_blob(
|
|||
image_urls = [img.img_url for img in result_images]
|
||||
|
||||
total_time = time.perf_counter() - request_start
|
||||
logger.info(f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
|
||||
logger.info(
|
||||
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||
f"total: {saved_count}, total_time: {total_time * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
return ImageUploadResponse(
|
||||
task_id=task_id,
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ async def generate_song(
|
|||
project = project_result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"[generate_song] Project NOT FOUND - task_id: {task_id}")
|
||||
logger.warning(
|
||||
f"[generate_song] Project NOT FOUND - task_id: {task_id}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||
|
|
@ -143,6 +145,13 @@ async def generate_song(
|
|||
.limit(1)
|
||||
)
|
||||
lyric = lyric_result.scalar_one_or_none()
|
||||
logger.debug(
|
||||
f"[generate_song] Lyric query result - "
|
||||
f"id: {lyric.id if lyric else None}, "
|
||||
f"project_id: {lyric.project_id if lyric else None}, "
|
||||
f"task_id: {lyric.task_id if lyric else None}, "
|
||||
f"lyric_result: {lyric.lyric_result if lyric else None}"
|
||||
)
|
||||
|
||||
if not lyric:
|
||||
logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
|
||||
|
|
@ -156,13 +165,25 @@ async def generate_song(
|
|||
logger.info(
|
||||
f"[generate_song] Queries completed - task_id: {task_id}, "
|
||||
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
||||
f"elapsed: {(query_time - request_start)*1000:.1f}ms"
|
||||
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
# Song 테이블에 초기 데이터 저장
|
||||
song_prompt = (
|
||||
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
|
||||
f"{'=' * 60}\n"
|
||||
f"[lyric.lyric_result]\n"
|
||||
f"{'-' * 60}\n"
|
||||
f"{lyric.lyric_result}\n"
|
||||
f"{'=' * 60}\n"
|
||||
f"[song_prompt]\n"
|
||||
f"{'-' * 60}\n"
|
||||
f"{song_prompt}\n"
|
||||
f"{'=' * 60}"
|
||||
)
|
||||
|
||||
song = Song(
|
||||
project_id=project_id,
|
||||
|
|
@ -181,7 +202,7 @@ async def generate_song(
|
|||
logger.info(
|
||||
f"[generate_song] Stage 1 DONE - Song saved - "
|
||||
f"task_id: {task_id}, song_id: {song_id}, "
|
||||
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms"
|
||||
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||
)
|
||||
# 세션이 여기서 자동으로 닫힘
|
||||
|
||||
|
|
@ -218,7 +239,7 @@ async def generate_song(
|
|||
logger.info(
|
||||
f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
|
||||
f"suno_task_id: {suno_task_id}, "
|
||||
f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
|
||||
f"elapsed: {(stage2_time - stage2_start) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -264,12 +285,12 @@ async def generate_song(
|
|||
total_time = stage3_time - request_start
|
||||
logger.info(
|
||||
f"[generate_song] Stage 3 DONE - task_id: {task_id}, "
|
||||
f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
|
||||
f"elapsed: {(stage3_time - stage3_start) * 1000:.1f}ms"
|
||||
)
|
||||
logger.info(
|
||||
f"[generate_song] SUCCESS - task_id: {task_id}, "
|
||||
f"suno_task_id: {suno_task_id}, "
|
||||
f"total_time: {total_time*1000:.1f}ms"
|
||||
f"total_time: {total_time * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
return GenerateSongResponse(
|
||||
|
|
@ -344,14 +365,22 @@ async def get_song_status(
|
|||
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
||||
song_result_url을 Blob URL로 업데이트합니다.
|
||||
"""
|
||||
suno_task_id = song_id # 임시방편 / 외부 suno 노출 방지
|
||||
suno_task_id = song_id # 임시방편 / 외부 suno 노출 방지
|
||||
logger.info(f"[get_song_status] START - song_id: {suno_task_id}")
|
||||
try:
|
||||
suno_service = SunoService()
|
||||
result = await suno_service.get_task_status(suno_task_id)
|
||||
logger.debug(f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}")
|
||||
logger.debug(
|
||||
f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}"
|
||||
)
|
||||
parsed_response = suno_service.parse_status_response(result)
|
||||
logger.info(f"[get_song_status] Suno API response - song_id: {suno_task_id}, status: {parsed_response.status}")
|
||||
logger.info(
|
||||
f"[get_song_status] Suno API response - song_id: {suno_task_id}, status: {parsed_response.status}"
|
||||
)
|
||||
|
||||
if parsed_response.status == "TEXT_SUCCESS" and result:
|
||||
parsed_response.status = "processing"
|
||||
return parsed_response
|
||||
|
||||
# SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행
|
||||
if parsed_response.status == "SUCCESS" and result:
|
||||
|
|
@ -365,7 +394,9 @@ async def get_song_status(
|
|||
first_clip = clips_data[0]
|
||||
audio_url = first_clip.get("audioUrl")
|
||||
clip_duration = first_clip.get("duration")
|
||||
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}")
|
||||
logger.debug(
|
||||
f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
|
||||
)
|
||||
|
||||
if audio_url:
|
||||
# song_id로 Song 조회
|
||||
|
|
@ -376,7 +407,7 @@ async def get_song_status(
|
|||
.limit(1)
|
||||
)
|
||||
song = song_result.scalar_one_or_none()
|
||||
|
||||
|
||||
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
||||
if song and song.status == "processing":
|
||||
# store_name 조회
|
||||
|
|
@ -388,9 +419,11 @@ async def get_song_status(
|
|||
|
||||
# 상태를 uploading으로 변경 (중복 호출 방지)
|
||||
song.status = "uploading"
|
||||
song.suno_audio_id = first_clip.get('id')
|
||||
song.suno_audio_id = first_clip.get("id")
|
||||
await session.commit()
|
||||
logger.info(f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}")
|
||||
logger.info(
|
||||
f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}"
|
||||
)
|
||||
|
||||
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
|
||||
background_tasks.add_task(
|
||||
|
|
@ -400,10 +433,19 @@ async def get_song_status(
|
|||
store_name=store_name,
|
||||
duration=clip_duration,
|
||||
)
|
||||
logger.info(f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}")
|
||||
logger.info(
|
||||
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}"
|
||||
)
|
||||
|
||||
suno_audio_id = first_clip.get('id')
|
||||
word_data = await suno_service.get_lyric_timestamp(suno_task_id, suno_audio_id)
|
||||
suno_audio_id = first_clip.get("id")
|
||||
word_data = await suno_service.get_lyric_timestamp(
|
||||
suno_task_id, suno_audio_id
|
||||
)
|
||||
logger.debug(
|
||||
f"[get_song_status] word_data from get_lyric_timestamp - "
|
||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
||||
f"word_data: {word_data}"
|
||||
)
|
||||
lyric_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == song.task_id)
|
||||
|
|
@ -411,30 +453,52 @@ async def get_song_status(
|
|||
.limit(1)
|
||||
)
|
||||
lyric = lyric_result.scalar_one_or_none()
|
||||
gt_lyric = lyric.lyric_result
|
||||
gt_lyric = lyric.lyric_result
|
||||
lyric_line_list = gt_lyric.split("\n")
|
||||
sentences = [lyric_line.strip(',. ') for lyric_line in lyric_line_list if lyric_line and lyric_line != "---"]
|
||||
sentences = [
|
||||
lyric_line.strip(",. ")
|
||||
for lyric_line in lyric_line_list
|
||||
if lyric_line and lyric_line != "---"
|
||||
]
|
||||
logger.debug(
|
||||
f"[get_song_status] sentences from lyric - "
|
||||
f"sentences: {sentences}"
|
||||
)
|
||||
|
||||
timestamped_lyrics = suno_service.align_lyrics(
|
||||
word_data, sentences
|
||||
)
|
||||
logger.debug(
|
||||
f"[get_song_status] sentences from lyric - "
|
||||
f"sentences: {sentences}"
|
||||
)
|
||||
|
||||
timestamped_lyrics = suno_service.align_lyrics(word_data, sentences)
|
||||
# TODO : DB upload timestamped_lyrics
|
||||
for order_idx, timestamped_lyric in enumerate(timestamped_lyrics):
|
||||
for order_idx, timestamped_lyric in enumerate(
|
||||
timestamped_lyrics
|
||||
):
|
||||
song_timestamp = SongTimestamp(
|
||||
suno_audio_id = suno_audio_id,
|
||||
order_idx = order_idx,
|
||||
lyric_line = timestamped_lyric["text"],
|
||||
start_time = timestamped_lyric["start_sec"],
|
||||
end_time = timestamped_lyric["end_sec"]
|
||||
suno_audio_id=suno_audio_id,
|
||||
order_idx=order_idx,
|
||||
lyric_line=timestamped_lyric["text"],
|
||||
start_time=timestamped_lyric["start_sec"],
|
||||
end_time=timestamped_lyric["end_sec"],
|
||||
)
|
||||
session.add(song_timestamp)
|
||||
|
||||
|
||||
await session.commit()
|
||||
|
||||
elif song and song.status == "uploading":
|
||||
logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}")
|
||||
logger.info(
|
||||
f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}"
|
||||
)
|
||||
parsed_response.status = "uploading"
|
||||
elif song and song.status == "completed":
|
||||
logger.info(f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}")
|
||||
logger.info(
|
||||
f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}"
|
||||
)
|
||||
|
||||
logger.info(f"[get_song_status] SUCCESS - song_id: {suno_task_id}")
|
||||
logger.info(f"[get_song_status] END - song_id: {suno_task_id}")
|
||||
return parsed_response
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -520,7 +584,9 @@ async def download_song(
|
|||
error_message="Song not found",
|
||||
)
|
||||
|
||||
logger.info(f"[download_song] Song found - task_id: {task_id}, status: {song.status}")
|
||||
logger.info(
|
||||
f"[download_song] Song found - task_id: {task_id}, status: {song.status}"
|
||||
)
|
||||
|
||||
# processing 상태인 경우
|
||||
if song.status == "processing":
|
||||
|
|
@ -559,7 +625,9 @@ async def download_song(
|
|||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
|
||||
logger.info(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}")
|
||||
logger.info(
|
||||
f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}"
|
||||
)
|
||||
return DownloadSongResponse(
|
||||
success=True,
|
||||
status="completed",
|
||||
|
|
@ -621,7 +689,9 @@ async def get_songs(
|
|||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
) -> PaginatedResponse[SongListItem]:
|
||||
"""완료된 노래 목록을 페이지네이션하여 반환합니다."""
|
||||
logger.info(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}")
|
||||
logger.info(
|
||||
f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
)
|
||||
try:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
|
|
@ -630,10 +700,7 @@ async def get_songs(
|
|||
|
||||
# task_id별 최신 created_at 조회
|
||||
latest_subquery = (
|
||||
select(
|
||||
Song.task_id,
|
||||
func.max(Song.created_at).label("max_created_at")
|
||||
)
|
||||
select(Song.task_id, func.max(Song.created_at).label("max_created_at"))
|
||||
.where(Song.status == "completed")
|
||||
.group_by(Song.task_id)
|
||||
.subquery()
|
||||
|
|
@ -651,8 +718,8 @@ async def get_songs(
|
|||
latest_subquery,
|
||||
and_(
|
||||
Song.task_id == latest_subquery.c.task_id,
|
||||
Song.created_at == latest_subquery.c.max_created_at
|
||||
)
|
||||
Song.created_at == latest_subquery.c.max_created_at,
|
||||
),
|
||||
)
|
||||
.where(Song.status == "completed")
|
||||
.order_by(Song.created_at.desc())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.song.models import Song
|
||||
from app.song.models import Song, SongTimestamp
|
||||
|
||||
|
||||
class SongAdmin(ModelView, model=Song):
|
||||
|
|
@ -67,3 +67,59 @@ class SongAdmin(ModelView, model=Song):
|
|||
"song_result_url": "결과 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
||||
|
||||
class SongTimestampAdmin(ModelView, model=SongTimestamp):
|
||||
name = "노래 타임스탬프"
|
||||
name_plural = "노래 타임스탬프 목록"
|
||||
icon = "fa-solid fa-clock"
|
||||
category = "노래 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"suno_audio_id",
|
||||
"order_idx",
|
||||
"lyric_line",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"suno_audio_id",
|
||||
"order_idx",
|
||||
"lyric_line",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at"]
|
||||
|
||||
column_searchable_list = [
|
||||
SongTimestamp.suno_audio_id,
|
||||
SongTimestamp.lyric_line,
|
||||
]
|
||||
|
||||
column_default_sort = (SongTimestamp.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
SongTimestamp.id,
|
||||
SongTimestamp.suno_audio_id,
|
||||
SongTimestamp.order_idx,
|
||||
SongTimestamp.start_time,
|
||||
SongTimestamp.end_time,
|
||||
SongTimestamp.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"suno_audio_id": "Suno 오디오 ID",
|
||||
"order_idx": "순서",
|
||||
"lyric_line": "가사",
|
||||
"start_time": "시작 시간",
|
||||
"end_time": "종료 시간",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.user.models import RefreshToken, SocialAccount, User
|
||||
|
||||
|
||||
class UserAdmin(ModelView, model=User):
|
||||
name = "사용자"
|
||||
name_plural = "사용자 목록"
|
||||
icon = "fa-solid fa-user"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"kakao_id",
|
||||
"email",
|
||||
"nickname",
|
||||
"role",
|
||||
"is_active",
|
||||
"is_deleted",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"kakao_id",
|
||||
"email",
|
||||
"nickname",
|
||||
"profile_image_url",
|
||||
"thumbnail_image_url",
|
||||
"phone",
|
||||
"name",
|
||||
"birth_date",
|
||||
"gender",
|
||||
"is_active",
|
||||
"is_admin",
|
||||
"role",
|
||||
"is_deleted",
|
||||
"deleted_at",
|
||||
"last_login_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user_projects",
|
||||
"refresh_tokens",
|
||||
"social_accounts",
|
||||
]
|
||||
|
||||
column_searchable_list = [
|
||||
User.kakao_id,
|
||||
User.email,
|
||||
User.nickname,
|
||||
User.phone,
|
||||
User.name,
|
||||
]
|
||||
|
||||
column_default_sort = (User.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
User.id,
|
||||
User.kakao_id,
|
||||
User.email,
|
||||
User.nickname,
|
||||
User.role,
|
||||
User.is_active,
|
||||
User.is_deleted,
|
||||
User.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"kakao_id": "카카오 ID",
|
||||
"email": "이메일",
|
||||
"nickname": "닉네임",
|
||||
"profile_image_url": "프로필 이미지",
|
||||
"thumbnail_image_url": "썸네일 이미지",
|
||||
"phone": "전화번호",
|
||||
"name": "실명",
|
||||
"birth_date": "생년월일",
|
||||
"gender": "성별",
|
||||
"is_active": "활성화",
|
||||
"is_admin": "관리자",
|
||||
"role": "권한",
|
||||
"is_deleted": "삭제됨",
|
||||
"deleted_at": "삭제일시",
|
||||
"last_login_at": "마지막 로그인",
|
||||
"created_at": "생성일시",
|
||||
"updated_at": "수정일시",
|
||||
}
|
||||
|
||||
|
||||
class RefreshTokenAdmin(ModelView, model=RefreshToken):
|
||||
name = "리프레시 토큰"
|
||||
name_plural = "리프레시 토큰 목록"
|
||||
icon = "fa-solid fa-key"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"is_revoked",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"token_hash",
|
||||
"expires_at",
|
||||
"is_revoked",
|
||||
"created_at",
|
||||
"revoked_at",
|
||||
"user_agent",
|
||||
"ip_address",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at", "user"]
|
||||
|
||||
column_searchable_list = [
|
||||
RefreshToken.user_id,
|
||||
RefreshToken.token_hash,
|
||||
RefreshToken.ip_address,
|
||||
]
|
||||
|
||||
column_default_sort = (RefreshToken.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
RefreshToken.id,
|
||||
RefreshToken.user_id,
|
||||
RefreshToken.is_revoked,
|
||||
RefreshToken.expires_at,
|
||||
RefreshToken.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_id": "사용자 ID",
|
||||
"token_hash": "토큰 해시",
|
||||
"expires_at": "만료일시",
|
||||
"is_revoked": "폐기됨",
|
||||
"created_at": "생성일시",
|
||||
"revoked_at": "폐기일시",
|
||||
"user_agent": "User Agent",
|
||||
"ip_address": "IP 주소",
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||
name = "소셜 계정"
|
||||
name_plural = "소셜 계정 목록"
|
||||
icon = "fa-solid fa-share-nodes"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_username",
|
||||
"is_active",
|
||||
"connected_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_user_id",
|
||||
"platform_username",
|
||||
"platform_data",
|
||||
"scope",
|
||||
"token_expires_at",
|
||||
"is_active",
|
||||
"connected_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["connected_at", "updated_at", "user"]
|
||||
|
||||
column_searchable_list = [
|
||||
SocialAccount.user_id,
|
||||
SocialAccount.platform,
|
||||
SocialAccount.platform_user_id,
|
||||
SocialAccount.platform_username,
|
||||
]
|
||||
|
||||
column_default_sort = (SocialAccount.connected_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
SocialAccount.id,
|
||||
SocialAccount.user_id,
|
||||
SocialAccount.platform,
|
||||
SocialAccount.is_active,
|
||||
SocialAccount.connected_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_id": "사용자 ID",
|
||||
"platform": "플랫폼",
|
||||
"platform_user_id": "플랫폼 사용자 ID",
|
||||
"platform_username": "플랫폼 사용자명",
|
||||
"platform_data": "플랫폼 데이터",
|
||||
"scope": "권한 범위",
|
||||
"token_expires_at": "토큰 만료일시",
|
||||
"is_active": "활성화",
|
||||
"connected_at": "연동일시",
|
||||
"updated_at": "수정일시",
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ class AuthService:
|
|||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
redirect_url = f"https://{prj_settings.PROJECT_DOMAIN}"
|
||||
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
|
||||
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, redirect_url: {redirect_url}")
|
||||
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
|
||||
|
||||
|
|
|
|||
|
|
@ -254,14 +254,12 @@ def setup_uvicorn_logging() -> dict:
|
|||
# dictConfig 버전 (필수, 항상 1)
|
||||
# --------------------------------------------------------
|
||||
"version": 1,
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 기존 로거 비활성화 여부
|
||||
# False: 기존 로거 유지 (권장)
|
||||
# True: 기존 로거 모두 비활성화
|
||||
# --------------------------------------------------------
|
||||
"disable_existing_loggers": False,
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 포맷터 정의
|
||||
# 로그 메시지의 출력 형식을 지정합니다.
|
||||
|
|
@ -276,12 +274,11 @@ def setup_uvicorn_logging() -> dict:
|
|||
# HTTP 요청 로그용 포맷터
|
||||
# 사용 가능한 변수: client_addr, request_line, status_code
|
||||
"access": {
|
||||
"format": "[{asctime}] {levelname:8} [{name}] {client_addr} - \"{request_line}\" {status_code}",
|
||||
"format": '[{asctime}] {levelname:8} [{name}] {client_addr} - "{request_line}" {status_code}',
|
||||
"datefmt": LoggerConfig.DATE_FORMAT,
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 핸들러 정의
|
||||
# 로그를 어디에 출력할지 지정합니다.
|
||||
|
|
@ -300,7 +297,6 @@ def setup_uvicorn_logging() -> dict:
|
|||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
},
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 로거 정의
|
||||
# Uvicorn 내부에서 사용하는 로거들을 설정합니다.
|
||||
|
|
@ -309,19 +305,19 @@ def setup_uvicorn_logging() -> dict:
|
|||
# Uvicorn 메인 로거
|
||||
"uvicorn": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"level": "DEBUG",
|
||||
"propagate": False, # 상위 로거로 전파 방지
|
||||
},
|
||||
# 에러/시작/종료 로그
|
||||
"uvicorn.error": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
# HTTP 요청 로그 (GET /path HTTP/1.1 200 등)
|
||||
"uvicorn.access": {
|
||||
"handlers": ["access"],
|
||||
"level": "INFO",
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"model": "gpt-5-mini",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info",
|
||||
"marketing_intelligence_summary",
|
||||
"language",
|
||||
"promotional_expression_example",
|
||||
"timing_rules"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "lyric",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lyric": {
|
||||
"type": "string"
|
||||
},
|
||||
"suno_prompt":{
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lyric", "suno_prompt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
|
||||
[ROLE]
|
||||
You are a content marketing expert, brand strategist, and creative songwriter
|
||||
specializing in Korean pension / accommodation businesses.
|
||||
You create lyrics strictly based on Brand & Marketing Intelligence analysis
|
||||
and optimized for viral short-form video content.
|
||||
|
||||
[INPUT]
|
||||
Business Name: {customer_name}
|
||||
Region: {region}
|
||||
Region Details: {detail_region_info}
|
||||
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
|
||||
Output Language: {language}
|
||||
|
||||
[INTERNAL ANALYSIS – DO NOT OUTPUT]
|
||||
Internally analyze the following to guide all creative decisions:
|
||||
- Core brand identity and positioning
|
||||
- Emotional hooks derived from selling points
|
||||
- Target audience lifestyle, desires, and travel motivation
|
||||
- Regional atmosphere and symbolic imagery
|
||||
- How the stay converts into “shareable moments”
|
||||
- Which selling points must surface implicitly in lyrics
|
||||
|
||||
[LYRICS & MUSIC CREATION TASK]
|
||||
Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:
|
||||
- Original promotional lyrics
|
||||
- Music attributes for AI music generation (Suno-compatible prompt)
|
||||
The output must be designed for VIRAL DIGITAL CONTENT
|
||||
(short-form video, reels, ads).
|
||||
|
||||
[LYRICS REQUIREMENTS]
|
||||
Mandatory Inclusions:
|
||||
- Business name
|
||||
- Region name
|
||||
- Promotion subject
|
||||
- Promotional expressions including:
|
||||
{promotional_expression_example}
|
||||
|
||||
Content Rules:
|
||||
- Lyrics must be emotionally driven, not descriptive listings
|
||||
- Selling points must be IMPLIED, not explained
|
||||
- Must sound natural when sung
|
||||
- Must feel like a lifestyle moment, not an advertisement
|
||||
|
||||
Tone & Style:
|
||||
- Warm, emotional, and aspirational
|
||||
- Trendy, viral-friendly phrasing
|
||||
- Calm but memorable hooks
|
||||
- Suitable for travel / stay-related content
|
||||
|
||||
[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]
|
||||
After the lyrics, generate a concise music prompt including:
|
||||
Song mood (emotional keywords)
|
||||
BPM range
|
||||
Recommended genres (max 2)
|
||||
Key musical motifs or instruments
|
||||
Overall vibe (1 short sentence)
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE 100% WRITTEN IN {language}.
|
||||
no mixed languages
|
||||
All names, places, and expressions must be in {language}
|
||||
Any violation invalidates the entire output
|
||||
|
||||
[OUTPUT RULES – STRICT]
|
||||
{timing_rules}
|
||||
|
||||
No explanations
|
||||
No headings
|
||||
No bullet points
|
||||
No analysis
|
||||
No extra text
|
||||
|
||||
[FAILURE FORMAT]
|
||||
If generation is impossible:
|
||||
ERROR: Brief reason in English
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"model": "gpt-5.2",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "report",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "string"
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
|
||||
[Role & Objective]
|
||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[Core Analysis Requirements]
|
||||
Analyze the property based on:
|
||||
Location, concept, and nearby environment
|
||||
Target customer behavior and reservation decision factors
|
||||
Include:
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Competitive landscape (direct & indirect competitors)
|
||||
- Market positioning
|
||||
|
||||
[Key Selling Point Structuring – UI Optimized]
|
||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
||||
Rules:
|
||||
Focus only on factors that directly influence booking decisions
|
||||
Each selling point must be concise and visually scannable
|
||||
Language must be reusable for ads, short-form videos, and listing headlines
|
||||
Avoid full sentences in descriptions; use short selling phrases
|
||||
Do not provide in report
|
||||
|
||||
Output format:
|
||||
[Category]
|
||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
||||
One-line selling phrase (not a full sentence)
|
||||
Limit:
|
||||
5 to 8 Key Selling Points only
|
||||
Do not provide in report
|
||||
|
||||
[Content & Automation Readiness Check]
|
||||
Ensure that:
|
||||
Each tag keyword can directly map to a content theme
|
||||
Each selling phrase can be used as:
|
||||
- Video hook
|
||||
- Image headline
|
||||
- Ad copy snippet
|
||||
|
||||
|
||||
[Tag Generation Rules]
|
||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
||||
- The number of tags must be **exactly 5**
|
||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
||||
- The following categories must be **balanced and all represented**:
|
||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
||||
|
||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
||||
- The final output must strictly follow the JSON format below, with no additional text
|
||||
|
||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
|
||||
[Role & Objective]
|
||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[Core Analysis Requirements]
|
||||
Analyze the property based on:
|
||||
Location, concept, photos, online presence, and nearby environment
|
||||
Target customer behavior and reservation decision factors
|
||||
Include:
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Competitive landscape (direct & indirect competitors)
|
||||
- Market positioning
|
||||
|
||||
[Key Selling Point Structuring – UI Optimized]
|
||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
||||
Rules:
|
||||
Focus only on factors that directly influence booking decisions
|
||||
Each selling point must be concise and visually scannable
|
||||
Language must be reusable for ads, short-form videos, and listing headlines
|
||||
Avoid full sentences in descriptions; use short selling phrases
|
||||
|
||||
Output format:
|
||||
[Category]
|
||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
||||
One-line selling phrase (not a full sentence)
|
||||
Limit:
|
||||
5 to 8 Key Selling Points only
|
||||
|
||||
[Content & Automation Readiness Check]
|
||||
Ensure that:
|
||||
Each tag keyword can directly map to a content theme
|
||||
Each selling phrase can be used as:
|
||||
- Video hook
|
||||
- Image headline
|
||||
- Ad copy snippet
|
||||
|
||||
|
||||
[Tag Generation Rules]
|
||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
||||
- The number of tags must be **exactly 5**
|
||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
||||
- The following categories must be **balanced and all represented**:
|
||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
||||
|
||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
||||
- The final output must strictly follow the JSON format below, with no additional text
|
||||
|
||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import os, json
|
||||
from abc import ABCMeta
|
||||
from config import prompt_settings
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("prompt")
|
||||
|
||||
class Prompt():
|
||||
prompt_name : str # ex) marketing_prompt
|
||||
prompt_template_path : str #프롬프트 경로
|
||||
prompt_template : str # fstring 포맷
|
||||
prompt_input : list
|
||||
prompt_output : dict
|
||||
prompt_model : str
|
||||
|
||||
def __init__(self, prompt_name, prompt_template_path):
|
||||
self.prompt_name = prompt_name
|
||||
self.prompt_template_path = prompt_template_path
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
|
||||
def _reload_prompt(self):
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
|
||||
def read_prompt(self) -> tuple[str, dict]:
|
||||
template_text_path = self.prompt_template_path + ".txt"
|
||||
prompt_dict_path = self.prompt_template_path + ".json"
|
||||
with open(template_text_path, "r") as fp:
|
||||
prompt_template = fp.read()
|
||||
with open(prompt_dict_path, "r") as fp:
|
||||
prompt_dict = json.load(fp)
|
||||
|
||||
return prompt_template, prompt_dict
|
||||
|
||||
def build_prompt(self, input_data:dict) -> str:
|
||||
self.check_input(input_data)
|
||||
build_template = self.prompt_template
|
||||
logger.debug(f"build_template: {build_template}")
|
||||
logger.debug(f"input_data: {input_data}")
|
||||
build_template = build_template.format(**input_data)
|
||||
return build_template
|
||||
|
||||
def check_input(self, input_data:dict) -> bool:
|
||||
missing_variables = input_data.keys() - set(self.prompt_input)
|
||||
if missing_variables:
|
||||
raise Exception(f"missing_variable for prompt {self.prompt_name} : {missing_variables}")
|
||||
|
||||
flooding_variables = set(self.prompt_input) - input_data.keys()
|
||||
if flooding_variables:
|
||||
raise Exception(f"flooding_variables for prompt {self.prompt_name} : {flooding_variables}")
|
||||
return True
|
||||
|
||||
marketing_prompt = Prompt(
|
||||
prompt_name=prompt_settings.MARKETING_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_NAME)
|
||||
)
|
||||
|
||||
summarize_prompt = Prompt(
|
||||
prompt_name=prompt_settings.SUMMARIZE_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.SUMMARIZE_PROMPT_NAME)
|
||||
)
|
||||
|
||||
lyric_prompt = Prompt(
|
||||
prompt_name=prompt_settings.LYLIC_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYLIC_PROMPT_NAME)
|
||||
)
|
||||
|
||||
def reload_all_prompt():
|
||||
marketing_prompt._reload_prompt()
|
||||
summarize_prompt._reload_prompt()
|
||||
lyric_prompt._reload_prompt()
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"prompt_variables": [
|
||||
"report",
|
||||
"selling_points"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "tags",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag_keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"tag_keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
|
||||
입력 :
|
||||
분석 보고서
|
||||
{report}
|
||||
|
||||
셀링 포인트
|
||||
{selling_points}
|
||||
|
||||
위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.
|
||||
|
||||
조건:
|
||||
각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것
|
||||
태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여
|
||||
- 3 ~ 6단어 이내
|
||||
- 명사 또는 명사형 키워드로 작성
|
||||
- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것
|
||||
- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함
|
||||
- 전체 셀링 포인트 개수는 5~7개로 제한
|
||||
|
||||
출력 형식:
|
||||
[카테고리명]
|
||||
(태그 키워드)
|
||||
- 한 줄 설명 문구
|
||||
|
||||
예시:
|
||||
[공간 정체성]
|
||||
(100년 적산가옥 · 시간의 결)
|
||||
- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간
|
||||
|
||||
[입지 & 희소성]
|
||||
(말랭이마을 · 로컬 히든플레이스)
|
||||
- 관광지가 아닌, 군산을 아는 사람의 선택
|
||||
|
||||
[프라이버시]
|
||||
(독채 숙소 · 프라이빗 스테이)
|
||||
- 누구의 방해도 없는 완전한 휴식 구조
|
||||
|
||||
[비주얼 경쟁력]
|
||||
(감성 인테리어 · 자연광 스폿)
|
||||
- 찍는 순간 콘텐츠가 되는 공간 설계
|
||||
|
||||
[타깃 최적화]
|
||||
(커플 · 소규모 여행)
|
||||
- 둘에게 가장 이상적인 공간 밀도
|
||||
|
||||
[체류 경험]
|
||||
(아무것도 안 해도 되는 하루)
|
||||
- 일정 없이도 만족되는 하루 루틴
|
||||
|
||||
[브랜드 포지션]
|
||||
(호텔도 펜션도 아닌 아지트)
|
||||
- 다시 돌아오고 싶은 개인적 장소
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"model": "gpt-5.2",
|
||||
"model": "gpt-5-mini",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
|
|
@ -13,7 +13,36 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "string"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail_title": {
|
||||
"type": "string"
|
||||
},
|
||||
"detail_description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"detail_title",
|
||||
"detail_description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary",
|
||||
"details"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
|
|
@ -43,12 +72,16 @@
|
|||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"contents_advise": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags"
|
||||
"tags",
|
||||
"contents_advise"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"model": "gpt-5.2",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "report",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "string"
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ class VideoAdmin(ModelView, model=Video):
|
|||
"lyric_id",
|
||||
"song_id",
|
||||
"task_id",
|
||||
"creatomate_render_id",
|
||||
"status",
|
||||
"result_movie_url",
|
||||
"created_at",
|
||||
|
|
@ -56,6 +57,7 @@ class VideoAdmin(ModelView, model=Video):
|
|||
"lyric_id": "가사 ID",
|
||||
"song_id": "노래 ID",
|
||||
"task_id": "작업 ID",
|
||||
"creatomate_render_id": "Creatomate 렌더 ID",
|
||||
"status": "상태",
|
||||
"result_movie_url": "영상 URL",
|
||||
"created_at": "생성일시",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
# Database INSERT 코드 위치 정리
|
||||
|
||||
이 문서는 O2O Castad Backend 프로젝트에서 실제 동작하는 모든 DB INSERT 코드의 위치를 정리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. User 모듈 (`app/user`)
|
||||
|
||||
### 1.1 User 모델 INSERT
|
||||
- **파일**: `app/user/services/auth.py`
|
||||
- **라인**: 278
|
||||
- **메서드**: `_get_or_create_user()`
|
||||
- **API 엔드포인트**:
|
||||
- `GET /auth/kakao/callback` - 카카오 로그인 콜백
|
||||
- `POST /auth/kakao/verify` - 카카오 인가 코드 검증
|
||||
- **설명**: 카카오 로그인 시 신규 사용자 생성
|
||||
- **저장 필드**: `kakao_id`, `email`, `nickname`, `profile_image_url`, `thumbnail_image_url`
|
||||
|
||||
### 1.2 RefreshToken 모델 INSERT
|
||||
- **파일**: `app/user/services/auth.py`
|
||||
- **라인**: 315
|
||||
- **메서드**: `_save_refresh_token()`
|
||||
- **API 엔드포인트**:
|
||||
- `GET /auth/kakao/callback` - 카카오 로그인 콜백
|
||||
- `POST /auth/kakao/verify` - 카카오 인가 코드 검증
|
||||
- **설명**: 로그인 시 리프레시 토큰 저장
|
||||
- **저장 필드**: `user_id`, `token_hash`, `expires_at`, `user_agent`, `ip_address`
|
||||
|
||||
---
|
||||
|
||||
## 2. Lyric 모듈 (`app/lyric`)
|
||||
|
||||
### 2.1 Project 모델 INSERT
|
||||
- **파일**: `app/lyric/api/routers/v1/lyric.py`
|
||||
- **라인**: 297
|
||||
- **API 엔드포인트**: `POST /lyric/generate` - 가사 생성
|
||||
- **설명**: 가사 생성 요청 시 프로젝트 레코드 생성
|
||||
- **저장 필드**: `store_name`, `region`, `task_id`, `detail_region_info`, `language`
|
||||
|
||||
### 2.2 Lyric 모델 INSERT
|
||||
- **파일**: `app/lyric/api/routers/v1/lyric.py`
|
||||
- **라인**: 317
|
||||
- **API 엔드포인트**: `POST /lyric/generate` - 가사 생성
|
||||
- **설명**: 가사 생성 요청 시 가사 레코드 생성 (초기 status: `processing`)
|
||||
- **저장 필드**: `project_id`, `task_id`, `status`, `lyric_prompt`, `lyric_result`, `language`
|
||||
|
||||
---
|
||||
|
||||
## 3. Song 모듈 (`app/song`)
|
||||
|
||||
### 3.1 Song 모델 INSERT
|
||||
- **파일**: `app/song/api/routers/v1/song.py`
|
||||
- **라인**: 178
|
||||
- **API 엔드포인트**: `POST /song/generate/{task_id}` - 노래 생성
|
||||
- **설명**: 노래 생성 요청 시 노래 레코드 생성 (초기 status: `processing`)
|
||||
- **저장 필드**: `project_id`, `lyric_id`, `task_id`, `suno_task_id`, `status`, `song_prompt`, `language`
|
||||
|
||||
### 3.2 SongTimestamp 모델 INSERT
|
||||
- **파일**: `app/song/api/routers/v1/song.py`
|
||||
- **라인**: 459
|
||||
- **API 엔드포인트**: `GET /song/status/{song_id}` - 노래 생성 상태 조회
|
||||
- **설명**: Suno API에서 노래 생성 완료(SUCCESS) 시 가사 타임스탬프 저장 (반복문으로 다건 INSERT)
|
||||
- **저장 필드**: `suno_audio_id`, `order_idx`, `lyric_line`, `start_time`, `end_time`
|
||||
|
||||
---
|
||||
|
||||
## 4. Video 모듈 (`app/video`)
|
||||
|
||||
### 4.1 Video 모델 INSERT
|
||||
- **파일**: `app/video/api/routers/v1/video.py`
|
||||
- **라인**: 247
|
||||
- **API 엔드포인트**: `GET /video/generate/{task_id}` - 영상 생성
|
||||
- **설명**: 영상 생성 요청 시 영상 레코드 생성 (초기 status: `processing`)
|
||||
- **저장 필드**: `project_id`, `lyric_id`, `song_id`, `task_id`, `creatomate_render_id`, `status`
|
||||
|
||||
---
|
||||
|
||||
## 5. Home 모듈 (`app/home`) - 이미지 업로드
|
||||
|
||||
### 5.1 Image 모델 INSERT (URL 이미지 - 서버 저장)
|
||||
- **파일**: `app/home/api/routers/v1/home.py`
|
||||
- **라인**: 486
|
||||
- **API 엔드포인트**: `POST /image/upload` (tags: Image-Server)
|
||||
- **설명**: 외부 URL 이미지를 Image 테이블에 저장
|
||||
- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order`
|
||||
|
||||
### 5.2 Image 모델 INSERT (바이너리 파일 - 서버 저장)
|
||||
- **파일**: `app/home/api/routers/v1/home.py`
|
||||
- **라인**: 535
|
||||
- **API 엔드포인트**: `POST /image/upload` (tags: Image-Server)
|
||||
- **설명**: 업로드된 바이너리 파일을 media 폴더에 저장 후 Image 테이블에 저장
|
||||
- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order`
|
||||
|
||||
### 5.3 Image 모델 INSERT (URL 이미지 - Blob)
|
||||
- **파일**: `app/home/api/routers/v1/home.py`
|
||||
- **라인**: 782
|
||||
- **API 엔드포인트**: `POST /image/upload/blob` (tags: Image-Blob)
|
||||
- **설명**: 외부 URL 이미지를 Image 테이블에 저장 (Azure Blob 엔드포인트)
|
||||
- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order`
|
||||
|
||||
### 5.4 Image 모델 INSERT (Blob 업로드 결과)
|
||||
- **파일**: `app/home/api/routers/v1/home.py`
|
||||
- **라인**: 804
|
||||
- **API 엔드포인트**: `POST /image/upload/blob` (tags: Image-Blob)
|
||||
- **설명**: 바이너리 파일을 Azure Blob Storage에 업로드 후 Image 테이블에 저장
|
||||
- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order`
|
||||
|
||||
---
|
||||
|
||||
## 6. Base Services (미사용)
|
||||
|
||||
아래 파일들은 공통 `_add()` 메서드를 정의하고 있으나, 현재 실제 API에서 직접 호출되지 않습니다.
|
||||
|
||||
| 파일 | 라인 | 비고 |
|
||||
|------|------|------|
|
||||
| `app/lyric/services/base.py` | 15 | `_update()`에서만 내부 호출 |
|
||||
| `app/home/services/base.py` | 15 | `_update()`에서만 내부 호출 |
|
||||
| `app/song/services/base.py` | 15 | `_update()`에서만 내부 호출 |
|
||||
|
||||
---
|
||||
|
||||
## 요약 테이블
|
||||
|
||||
| 모듈 | 모델 | 파일:라인 | API 엔드포인트 |
|
||||
|------|------|-----------|----------------|
|
||||
| User | User | `auth.py:278` | `GET /auth/kakao/callback`, `POST /auth/kakao/verify` |
|
||||
| User | RefreshToken | `auth.py:315` | `GET /auth/kakao/callback`, `POST /auth/kakao/verify` |
|
||||
| Lyric | Project | `lyric.py:297` | `POST /lyric/generate` |
|
||||
| Lyric | Lyric | `lyric.py:317` | `POST /lyric/generate` |
|
||||
| Song | Song | `song.py:178` | `POST /song/generate/{task_id}` |
|
||||
| Song | SongTimestamp | `song.py:459` | `GET /song/status/{song_id}` |
|
||||
| Video | Video | `video.py:247` | `GET /video/generate/{task_id}` |
|
||||
| Home | Image | `home.py:486` | `POST /image/upload` |
|
||||
| Home | Image | `home.py:535` | `POST /image/upload` |
|
||||
| Home | Image | `home.py:782` | `POST /image/upload/blob` |
|
||||
| Home | Image | `home.py:804` | `POST /image/upload/blob` |
|
||||
|
||||
---
|
||||
|
||||
*생성일: 2026-01-23*
|
||||
5
main.py
5
main.py
|
|
@ -124,10 +124,7 @@ def custom_openapi():
|
|||
for method, operation in path_item.items():
|
||||
if method in ["get", "post", "put", "patch", "delete"]:
|
||||
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
|
||||
if any(
|
||||
auth_path in path
|
||||
for auth_path in ["/auth/me", "/auth/logout"]
|
||||
):
|
||||
if any(auth_path in path for auth_path in ["/auth/me", "/auth/logout"]):
|
||||
operation["security"] = [{"BearerAuth": []}]
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ dependencies = [
|
|||
"openai>=2.13.0",
|
||||
"pydantic-settings>=2.12.0",
|
||||
"python-jose[cryptography]>=3.5.0",
|
||||
"python-multipart>=0.0.21",
|
||||
"redis>=7.1.0",
|
||||
"ruff>=0.14.9",
|
||||
"scalar-fastapi>=1.5.0",
|
||||
"scalar-fastapi>=1.6.1",
|
||||
"sqladmin[full]>=0.22.0",
|
||||
"sqlalchemy[asyncio]>=2.0.45",
|
||||
"uuid7>=0.1.0",
|
||||
|
|
|
|||
10
uv.lock
10
uv.lock
|
|
@ -883,6 +883,7 @@ dependencies = [
|
|||
{ name = "openai" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
{ name = "scalar-fastapi" },
|
||||
|
|
@ -909,9 +910,10 @@ requires-dist = [
|
|||
{ name = "openai", specifier = ">=2.13.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.21" },
|
||||
{ name = "redis", specifier = ">=7.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.9" },
|
||||
{ name = "scalar-fastapi", specifier = ">=1.5.0" },
|
||||
{ name = "scalar-fastapi", specifier = "==1.6.1" },
|
||||
{ name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" },
|
||||
{ name = "uuid7", specifier = ">=0.1.0" },
|
||||
|
|
@ -1382,11 +1384,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "scalar-fastapi"
|
||||
version = "1.5.0"
|
||||
version = "1.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/4897819ef5682f40cce1f9b552a1e002d394bf7e12e5d6fe62f843ffef51/scalar_fastapi-1.5.0.tar.gz", hash = "sha256:5ae887fcc0db63305dc41dc39d61f7be183256085872aa33b294e0d48e29901e", size = 7447, upload-time = "2025-12-04T19:44:12.04Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/56/bb31bc04d42b989db6933c2c3272d229d68baff0f95214e28e20886da8f6/scalar_fastapi-1.6.1.tar.gz", hash = "sha256:5d3cc96f0f384d388b58aba576a903b907e3c98dacab1339072210e946c4f033", size = 7753, upload-time = "2026-01-20T21:05:21.659Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/cc/c0712ac83a31ae96785d9916d7be54595914aa93375ea5df3ba3eadab8af/scalar_fastapi-1.5.0-py3-none-any.whl", hash = "sha256:8e712599ccdfbb614bff5583fdbee1bef03e5fac1e06520287337ff232cb667a", size = 6828, upload-time = "2025-12-04T19:44:11.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d9/6794a883410a01389ba6c7fece82f0586a57a7eaad9372b26016ad84e922/scalar_fastapi-1.6.1-py3-none-any.whl", hash = "sha256:880b3fa76b916fd2ee54b0fe340662944a1f95201f606902c2424b673c2cedaf", size = 7115, upload-time = "2026-01-20T21:05:20.457Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Reference in New Issue