데모버전 안정화
parent
7038faaf74
commit
1e16e0e3eb
|
|
@ -36,3 +36,5 @@ static/
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
.env*
|
||||||
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
||||||
from sqladmin import ModelView
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from app.home.models import Image, Project
|
from app.home.models import Image, Project, UserProject
|
||||||
|
|
||||||
|
|
||||||
class ProjectAdmin(ModelView, model=Project):
|
class ProjectAdmin(ModelView, model=Project):
|
||||||
|
|
@ -100,3 +100,44 @@ class ImageAdmin(ModelView, model=Image):
|
||||||
"img_url": "이미지 URL",
|
"img_url": "이미지 URL",
|
||||||
"created_at": "생성일시",
|
"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.common import generate_task_id
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
|
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
|
from config import MEDIA_ROOT
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
|
|
@ -119,14 +119,18 @@ async def crawling(request_body: CrawlingRequest):
|
||||||
await scraper.scrap()
|
await scraper.scrap()
|
||||||
except GraphQLException as e:
|
except GraphQLException as e:
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
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 상세 오류:")
|
logger.exception("[crawling] Step 1 상세 오류:")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
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
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
|
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: 정보 가공 ==========
|
# ========== Step 2: 정보 가공 ==========
|
||||||
step2_start = time.perf_counter()
|
step2_start = time.perf_counter()
|
||||||
|
|
@ -156,7 +162,9 @@ async def crawling(request_body: CrawlingRequest):
|
||||||
)
|
)
|
||||||
|
|
||||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
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 마케팅 분석 ==========
|
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
||||||
step3_start = time.perf_counter()
|
step3_start = time.perf_counter()
|
||||||
|
|
@ -167,14 +175,16 @@ async def crawling(request_body: CrawlingRequest):
|
||||||
step3_1_start = time.perf_counter()
|
step3_1_start = time.perf_counter()
|
||||||
chatgpt_service = ChatgptService()
|
chatgpt_service = ChatgptService()
|
||||||
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
|
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: 프롬프트 생성
|
# Step 3-2: 프롬프트 생성
|
||||||
# step3_2_start = time.perf_counter()
|
# step3_2_start = time.perf_counter()
|
||||||
input_marketing_data = {
|
input_marketing_data = {
|
||||||
"customer_name" : customer_name,
|
"customer_name": customer_name,
|
||||||
"region" : region,
|
"region": region,
|
||||||
"detail_region_info" : road_address or ""
|
"detail_region_info": road_address or "",
|
||||||
}
|
}
|
||||||
# prompt = chatgpt_service.build_market_analysis_prompt()
|
# prompt = chatgpt_service.build_market_analysis_prompt()
|
||||||
# prompt1 = marketing_prompt.build_prompt(input_marketing_data)
|
# prompt1 = marketing_prompt.build_prompt(input_marketing_data)
|
||||||
|
|
@ -182,47 +192,64 @@ async def crawling(request_body: CrawlingRequest):
|
||||||
|
|
||||||
# Step 3-3: GPT API 호출
|
# Step 3-3: GPT API 호출
|
||||||
step3_3_start = time.perf_counter()
|
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
|
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.info(
|
||||||
logger.debug(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)")
|
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 전달)
|
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
|
||||||
step3_4_start = time.perf_counter()
|
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에서 추출 중
|
# 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중
|
||||||
# parsed = await chatgpt_service.parse_marketing_analysis(
|
# parsed = await chatgpt_service.parse_marketing_analysis(
|
||||||
# structured_report, facility_info=scraper.facility_info
|
# structured_report, facility_info=scraper.facility_info
|
||||||
# )
|
# )
|
||||||
|
|
||||||
# marketing_analysis = MarketingAnalysis(**parsed)
|
# marketing_analysis = MarketingAnalysis(**parsed)
|
||||||
|
|
||||||
marketing_analysis = MarketingAnalysis(
|
marketing_analysis = MarketingAnalysis(
|
||||||
report=structured_report["report"],
|
report=structured_report["report"],
|
||||||
tags=structured_report["tags"],
|
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 구조
|
# Selling Points 구조
|
||||||
# print(sp['category'])
|
# print(sp['category'])
|
||||||
# print(sp['keywords'])
|
# print(sp['keywords'])
|
||||||
# print(sp['description'])
|
# print(sp['description'])
|
||||||
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
|
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
|
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:
|
except Exception as e:
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
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 상세 오류:")
|
logger.exception("[crawling] Step 3 상세 오류:")
|
||||||
# GPT 실패 시에도 크롤링 결과는 반환
|
# GPT 실패 시에도 크롤링 결과는 반환
|
||||||
marketing_analysis = None
|
marketing_analysis = None
|
||||||
else:
|
else:
|
||||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
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
|
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")
|
logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
|
||||||
if scraper.base_info:
|
if scraper.base_info:
|
||||||
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
|
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")
|
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")
|
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"image_list": scraper.image_link_list,
|
"image_list": scraper.image_link_list,
|
||||||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||||
"processed_info": processed_info,
|
"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},
|
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||||
},
|
},
|
||||||
tags=["Image-Blob"],
|
tags=["Image-Blob"],
|
||||||
|
openapi_extra={
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"encoding": {"files": {"contentType": "application/octet-stream"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def upload_images_blob(
|
async def upload_images_blob(
|
||||||
images_json: Optional[str] = Form(
|
images_json: Optional[str] = Form(
|
||||||
|
|
@ -606,7 +642,8 @@ async def upload_images_blob(
|
||||||
examples=[IMAGES_JSON_EXAMPLE],
|
examples=[IMAGES_JSON_EXAMPLE],
|
||||||
),
|
),
|
||||||
files: Optional[list[UploadFile]] = File(
|
files: Optional[list[UploadFile]] = File(
|
||||||
default=None, description="이미지 바이너리 파일 목록"
|
default=None,
|
||||||
|
description="이미지 바이너리 파일 목록",
|
||||||
),
|
),
|
||||||
) -> ImageUploadResponse:
|
) -> ImageUploadResponse:
|
||||||
"""이미지 업로드 (URL + Azure Blob Storage)
|
"""이미지 업로드 (URL + Azure Blob Storage)
|
||||||
|
|
@ -674,9 +711,11 @@ async def upload_images_blob(
|
||||||
)
|
)
|
||||||
|
|
||||||
stage1_time = time.perf_counter()
|
stage1_time = time.perf_counter()
|
||||||
logger.info(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
logger.info(
|
||||||
f"files: {len(valid_files_data)}, "
|
f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
||||||
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms")
|
f"files: {len(valid_files_data)}, "
|
||||||
|
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
|
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
|
||||||
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
|
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
|
||||||
|
|
@ -695,8 +734,10 @@ async def upload_images_blob(
|
||||||
)
|
)
|
||||||
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
||||||
|
|
||||||
logger.debug(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: "
|
logger.debug(
|
||||||
f"{filename} ({len(file_content)} bytes)")
|
f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: "
|
||||||
|
f"{filename} ({len(file_content)} bytes)"
|
||||||
|
)
|
||||||
|
|
||||||
# Azure Blob Storage에 직접 업로드
|
# Azure Blob Storage에 직접 업로드
|
||||||
upload_success = await uploader.upload_image_bytes(file_content, filename)
|
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_url = uploader.public_url
|
||||||
blob_upload_results.append((original_name, blob_url))
|
blob_upload_results.append((original_name, blob_url))
|
||||||
img_order += 1
|
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:
|
else:
|
||||||
skipped_files.append(filename)
|
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()
|
stage2_time = time.perf_counter()
|
||||||
logger.info(f"[upload_images_blob] Stage 2 done - blob uploads: "
|
logger.info(
|
||||||
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
|
f"[upload_images_blob] Stage 2 done - blob uploads: "
|
||||||
f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
|
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
|
||||||
|
f"elapsed: {(stage2_time - stage1_time) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
|
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
|
||||||
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
|
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
|
||||||
|
|
@ -724,9 +771,7 @@ async def upload_images_blob(
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# URL 이미지 저장
|
# URL 이미지 저장
|
||||||
for url_item in url_images:
|
for url_item in url_images:
|
||||||
img_name = (
|
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
||||||
url_item.name or _extract_image_name(url_item.url, img_order)
|
|
||||||
)
|
|
||||||
|
|
||||||
image = Image(
|
image = Image(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
|
@ -772,9 +817,11 @@ async def upload_images_blob(
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
stage3_time = time.perf_counter()
|
stage3_time = time.perf_counter()
|
||||||
logger.info(f"[upload_images_blob] Stage 3 done - "
|
logger.info(
|
||||||
f"saved: {len(result_images)}, "
|
f"[upload_images_blob] Stage 3 done - "
|
||||||
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
|
f"saved: {len(result_images)}, "
|
||||||
|
f"elapsed: {(stage3_time - stage2_time) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {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="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - "
|
logger.error(
|
||||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
|
f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||||
|
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
logger.exception("[upload_images_blob] Stage 3 상세 오류:")
|
logger.exception("[upload_images_blob] Stage 3 상세 오류:")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
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]
|
image_urls = [img.img_url for img in result_images]
|
||||||
|
|
||||||
total_time = time.perf_counter() - request_start
|
total_time = time.perf_counter() - request_start
|
||||||
logger.info(f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
logger.info(
|
||||||
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
|
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||||
|
f"total: {saved_count}, total_time: {total_time * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
return ImageUploadResponse(
|
return ImageUploadResponse(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,9 @@ async def generate_song(
|
||||||
project = project_result.scalar_one_or_none()
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
if not project:
|
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(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||||
|
|
@ -143,6 +145,13 @@ async def generate_song(
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
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:
|
if not lyric:
|
||||||
logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
|
logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
|
||||||
|
|
@ -156,13 +165,25 @@ async def generate_song(
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[generate_song] Queries completed - task_id: {task_id}, "
|
f"[generate_song] Queries completed - task_id: {task_id}, "
|
||||||
f"project_id: {project_id}, lyric_id: {lyric_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 테이블에 초기 데이터 저장
|
||||||
song_prompt = (
|
song_prompt = (
|
||||||
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
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(
|
song = Song(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
@ -181,7 +202,7 @@ async def generate_song(
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[generate_song] Stage 1 DONE - Song saved - "
|
f"[generate_song] Stage 1 DONE - Song saved - "
|
||||||
f"task_id: {task_id}, song_id: {song_id}, "
|
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(
|
logger.info(
|
||||||
f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
|
f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
|
||||||
f"suno_task_id: {suno_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:
|
except Exception as e:
|
||||||
|
|
@ -264,12 +285,12 @@ async def generate_song(
|
||||||
total_time = stage3_time - request_start
|
total_time = stage3_time - request_start
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[generate_song] Stage 3 DONE - task_id: {task_id}, "
|
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(
|
logger.info(
|
||||||
f"[generate_song] SUCCESS - task_id: {task_id}, "
|
f"[generate_song] SUCCESS - task_id: {task_id}, "
|
||||||
f"suno_task_id: {suno_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(
|
return GenerateSongResponse(
|
||||||
|
|
@ -344,14 +365,22 @@ async def get_song_status(
|
||||||
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
||||||
song_result_url을 Blob URL로 업데이트합니다.
|
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}")
|
logger.info(f"[get_song_status] START - song_id: {suno_task_id}")
|
||||||
try:
|
try:
|
||||||
suno_service = SunoService()
|
suno_service = SunoService()
|
||||||
result = await suno_service.get_task_status(suno_task_id)
|
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)
|
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 업로드 진행
|
# SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행
|
||||||
if parsed_response.status == "SUCCESS" and result:
|
if parsed_response.status == "SUCCESS" and result:
|
||||||
|
|
@ -365,7 +394,9 @@ async def get_song_status(
|
||||||
first_clip = clips_data[0]
|
first_clip = clips_data[0]
|
||||||
audio_url = first_clip.get("audioUrl")
|
audio_url = first_clip.get("audioUrl")
|
||||||
clip_duration = first_clip.get("duration")
|
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:
|
if audio_url:
|
||||||
# song_id로 Song 조회
|
# song_id로 Song 조회
|
||||||
|
|
@ -376,7 +407,7 @@ async def get_song_status(
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
song = song_result.scalar_one_or_none()
|
song = song_result.scalar_one_or_none()
|
||||||
|
|
||||||
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
||||||
if song and song.status == "processing":
|
if song and song.status == "processing":
|
||||||
# store_name 조회
|
# store_name 조회
|
||||||
|
|
@ -388,9 +419,11 @@ async def get_song_status(
|
||||||
|
|
||||||
# 상태를 uploading으로 변경 (중복 호출 방지)
|
# 상태를 uploading으로 변경 (중복 호출 방지)
|
||||||
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()
|
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 업로드 실행
|
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
|
|
@ -400,10 +433,19 @@ async def get_song_status(
|
||||||
store_name=store_name,
|
store_name=store_name,
|
||||||
duration=clip_duration,
|
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')
|
suno_audio_id = first_clip.get("id")
|
||||||
word_data = await suno_service.get_lyric_timestamp(suno_task_id, suno_audio_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(
|
lyric_result = await session.execute(
|
||||||
select(Lyric)
|
select(Lyric)
|
||||||
.where(Lyric.task_id == song.task_id)
|
.where(Lyric.task_id == song.task_id)
|
||||||
|
|
@ -411,30 +453,52 @@ async def get_song_status(
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
gt_lyric = lyric.lyric_result
|
gt_lyric = lyric.lyric_result
|
||||||
lyric_line_list = gt_lyric.split("\n")
|
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
|
# 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(
|
song_timestamp = SongTimestamp(
|
||||||
suno_audio_id = suno_audio_id,
|
suno_audio_id=suno_audio_id,
|
||||||
order_idx = order_idx,
|
order_idx=order_idx,
|
||||||
lyric_line = timestamped_lyric["text"],
|
lyric_line=timestamped_lyric["text"],
|
||||||
start_time = timestamped_lyric["start_sec"],
|
start_time=timestamped_lyric["start_sec"],
|
||||||
end_time = timestamped_lyric["end_sec"]
|
end_time=timestamped_lyric["end_sec"],
|
||||||
)
|
)
|
||||||
session.add(song_timestamp)
|
session.add(song_timestamp)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
elif song and song.status == "uploading":
|
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":
|
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
|
return parsed_response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -520,7 +584,9 @@ async def download_song(
|
||||||
error_message="Song not found",
|
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 상태인 경우
|
# processing 상태인 경우
|
||||||
if song.status == "processing":
|
if song.status == "processing":
|
||||||
|
|
@ -559,7 +625,9 @@ async def download_song(
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
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(
|
return DownloadSongResponse(
|
||||||
success=True,
|
success=True,
|
||||||
status="completed",
|
status="completed",
|
||||||
|
|
@ -621,7 +689,9 @@ async def get_songs(
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
pagination: PaginationParams = Depends(get_pagination_params),
|
||||||
) -> PaginatedResponse[SongListItem]:
|
) -> 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:
|
try:
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
offset = (pagination.page - 1) * pagination.page_size
|
||||||
|
|
||||||
|
|
@ -630,10 +700,7 @@ async def get_songs(
|
||||||
|
|
||||||
# task_id별 최신 created_at 조회
|
# task_id별 최신 created_at 조회
|
||||||
latest_subquery = (
|
latest_subquery = (
|
||||||
select(
|
select(Song.task_id, func.max(Song.created_at).label("max_created_at"))
|
||||||
Song.task_id,
|
|
||||||
func.max(Song.created_at).label("max_created_at")
|
|
||||||
)
|
|
||||||
.where(Song.status == "completed")
|
.where(Song.status == "completed")
|
||||||
.group_by(Song.task_id)
|
.group_by(Song.task_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
|
|
@ -651,8 +718,8 @@ async def get_songs(
|
||||||
latest_subquery,
|
latest_subquery,
|
||||||
and_(
|
and_(
|
||||||
Song.task_id == latest_subquery.c.task_id,
|
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")
|
.where(Song.status == "completed")
|
||||||
.order_by(Song.created_at.desc())
|
.order_by(Song.created_at.desc())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from sqladmin import ModelView
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from app.song.models import Song
|
from app.song.models import Song, SongTimestamp
|
||||||
|
|
||||||
|
|
||||||
class SongAdmin(ModelView, model=Song):
|
class SongAdmin(ModelView, model=Song):
|
||||||
|
|
@ -67,3 +67,59 @@ class SongAdmin(ModelView, model=Song):
|
||||||
"song_result_url": "결과 URL",
|
"song_result_url": "결과 URL",
|
||||||
"created_at": "생성일시",
|
"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)
|
user.last_login_at = datetime.now(timezone.utc)
|
||||||
await session.commit()
|
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.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]}...")
|
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)
|
# dictConfig 버전 (필수, 항상 1)
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# 기존 로거 비활성화 여부
|
# 기존 로거 비활성화 여부
|
||||||
# False: 기존 로거 유지 (권장)
|
# False: 기존 로거 유지 (권장)
|
||||||
# True: 기존 로거 모두 비활성화
|
# True: 기존 로거 모두 비활성화
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
|
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# 포맷터 정의
|
# 포맷터 정의
|
||||||
# 로그 메시지의 출력 형식을 지정합니다.
|
# 로그 메시지의 출력 형식을 지정합니다.
|
||||||
|
|
@ -276,12 +274,11 @@ def setup_uvicorn_logging() -> dict:
|
||||||
# HTTP 요청 로그용 포맷터
|
# HTTP 요청 로그용 포맷터
|
||||||
# 사용 가능한 변수: client_addr, request_line, status_code
|
# 사용 가능한 변수: client_addr, request_line, status_code
|
||||||
"access": {
|
"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,
|
"datefmt": LoggerConfig.DATE_FORMAT,
|
||||||
"style": "{",
|
"style": "{",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# 핸들러 정의
|
# 핸들러 정의
|
||||||
# 로그를 어디에 출력할지 지정합니다.
|
# 로그를 어디에 출력할지 지정합니다.
|
||||||
|
|
@ -300,7 +297,6 @@ def setup_uvicorn_logging() -> dict:
|
||||||
"stream": "ext://sys.stdout",
|
"stream": "ext://sys.stdout",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# 로거 정의
|
# 로거 정의
|
||||||
# Uvicorn 내부에서 사용하는 로거들을 설정합니다.
|
# Uvicorn 내부에서 사용하는 로거들을 설정합니다.
|
||||||
|
|
@ -309,19 +305,19 @@ def setup_uvicorn_logging() -> dict:
|
||||||
# Uvicorn 메인 로거
|
# Uvicorn 메인 로거
|
||||||
"uvicorn": {
|
"uvicorn": {
|
||||||
"handlers": ["default"],
|
"handlers": ["default"],
|
||||||
"level": "INFO",
|
"level": "DEBUG",
|
||||||
"propagate": False, # 상위 로거로 전파 방지
|
"propagate": False, # 상위 로거로 전파 방지
|
||||||
},
|
},
|
||||||
# 에러/시작/종료 로그
|
# 에러/시작/종료 로그
|
||||||
"uvicorn.error": {
|
"uvicorn.error": {
|
||||||
"handlers": ["default"],
|
"handlers": ["default"],
|
||||||
"level": "INFO",
|
"level": "DEBUG",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
# HTTP 요청 로그 (GET /path HTTP/1.1 200 등)
|
# HTTP 요청 로그 (GET /path HTTP/1.1 200 등)
|
||||||
"uvicorn.access": {
|
"uvicorn.access": {
|
||||||
"handlers": ["access"],
|
"handlers": ["access"],
|
||||||
"level": "INFO",
|
"level": "DEBUG",
|
||||||
"propagate": False,
|
"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": [
|
"prompt_variables": [
|
||||||
"customer_name",
|
"customer_name",
|
||||||
"region",
|
"region",
|
||||||
|
|
@ -13,7 +13,36 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"report": {
|
"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": {
|
"selling_points": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|
@ -43,12 +72,16 @@
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"contents_advise": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"report",
|
"report",
|
||||||
"selling_points",
|
"selling_points",
|
||||||
"tags"
|
"tags",
|
||||||
|
"contents_advise"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"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",
|
"lyric_id",
|
||||||
"song_id",
|
"song_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
|
"creatomate_render_id",
|
||||||
"status",
|
"status",
|
||||||
"result_movie_url",
|
"result_movie_url",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
|
@ -56,6 +57,7 @@ class VideoAdmin(ModelView, model=Video):
|
||||||
"lyric_id": "가사 ID",
|
"lyric_id": "가사 ID",
|
||||||
"song_id": "노래 ID",
|
"song_id": "노래 ID",
|
||||||
"task_id": "작업 ID",
|
"task_id": "작업 ID",
|
||||||
|
"creatomate_render_id": "Creatomate 렌더 ID",
|
||||||
"status": "상태",
|
"status": "상태",
|
||||||
"result_movie_url": "영상 URL",
|
"result_movie_url": "영상 URL",
|
||||||
"created_at": "생성일시",
|
"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():
|
for method, operation in path_item.items():
|
||||||
if method in ["get", "post", "put", "patch", "delete"]:
|
if method in ["get", "post", "put", "patch", "delete"]:
|
||||||
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
|
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
|
||||||
if any(
|
if any(auth_path in path for auth_path in ["/auth/me", "/auth/logout"]):
|
||||||
auth_path in path
|
|
||||||
for auth_path in ["/auth/me", "/auth/logout"]
|
|
||||||
):
|
|
||||||
operation["security"] = [{"BearerAuth": []}]
|
operation["security"] = [{"BearerAuth": []}]
|
||||||
|
|
||||||
app.openapi_schema = openapi_schema
|
app.openapi_schema = openapi_schema
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ dependencies = [
|
||||||
"openai>=2.13.0",
|
"openai>=2.13.0",
|
||||||
"pydantic-settings>=2.12.0",
|
"pydantic-settings>=2.12.0",
|
||||||
"python-jose[cryptography]>=3.5.0",
|
"python-jose[cryptography]>=3.5.0",
|
||||||
|
"python-multipart>=0.0.21",
|
||||||
"redis>=7.1.0",
|
"redis>=7.1.0",
|
||||||
"ruff>=0.14.9",
|
"ruff>=0.14.9",
|
||||||
"scalar-fastapi>=1.5.0",
|
"scalar-fastapi>=1.6.1",
|
||||||
"sqladmin[full]>=0.22.0",
|
"sqladmin[full]>=0.22.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.45",
|
"sqlalchemy[asyncio]>=2.0.45",
|
||||||
"uuid7>=0.1.0",
|
"uuid7>=0.1.0",
|
||||||
|
|
|
||||||
10
uv.lock
10
uv.lock
|
|
@ -883,6 +883,7 @@ dependencies = [
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "python-jose", extra = ["cryptography"] },
|
{ name = "python-jose", extra = ["cryptography"] },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "scalar-fastapi" },
|
{ name = "scalar-fastapi" },
|
||||||
|
|
@ -909,9 +910,10 @@ requires-dist = [
|
||||||
{ name = "openai", specifier = ">=2.13.0" },
|
{ name = "openai", specifier = ">=2.13.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.21" },
|
||||||
{ name = "redis", specifier = ">=7.1.0" },
|
{ name = "redis", specifier = ">=7.1.0" },
|
||||||
{ name = "ruff", specifier = ">=0.14.9" },
|
{ 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 = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" },
|
||||||
{ name = "uuid7", specifier = ">=0.1.0" },
|
{ name = "uuid7", specifier = ">=0.1.0" },
|
||||||
|
|
@ -1382,11 +1384,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scalar-fastapi"
|
name = "scalar-fastapi"
|
||||||
version = "1.5.0"
|
version = "1.6.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue