1199 lines
48 KiB
Python
1199 lines
48 KiB
Python
import asyncio
|
|
import json
|
|
import secrets
|
|
import time
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import Literal, Optional
|
|
|
|
from pydantic import BaseModel
|
|
from urllib.parse import unquote, urlparse
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import func, select
|
|
|
|
from app.database.session import get_session, AsyncSessionLocal
|
|
from app.home.models import Image, MarketingIntel, ImageTag
|
|
from app.user.dependencies.auth import get_current_user
|
|
from app.user.models import User
|
|
from app.home.schemas.home_schema import (
|
|
AutoCompleteRequest,
|
|
AccommodationSearchItem,
|
|
AccommodationSearchResponse,
|
|
CrawlingRequest,
|
|
CrawlingResponse,
|
|
ErrorResponse,
|
|
ImageUploadResponse,
|
|
ImageUrlItem,
|
|
ManualMarketingRequest,
|
|
ProcessedInfo,
|
|
# MarketingAnalysis,
|
|
)
|
|
from app.home.services.naver_search import naver_search_client
|
|
from app.home.services.image_upload import (
|
|
ALLOWED_IMAGE_EXTENSIONS,
|
|
BlobReferenceState,
|
|
ImageUploadLockTimeoutError,
|
|
assert_continuation_owner as _assert_continuation_owner,
|
|
compensate_failed_upload_blobs,
|
|
image_result_item as _image_result_item,
|
|
image_upload_task_lock,
|
|
inspect_upload_file as _inspect_upload_file,
|
|
is_valid_image_extension as _is_valid_image_extension,
|
|
normalize_continuation_task_id as _normalize_continuation_task_id,
|
|
validate_task_image_count,
|
|
)
|
|
from app.utils.upload_blob_as_request import (
|
|
AzureBlobUploader,
|
|
BlobUploadTooLargeError,
|
|
)
|
|
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
|
from app.utils.common import generate_task_id
|
|
from app.utils.logger import get_logger
|
|
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException
|
|
from app.utils.nvMapPwScraper import NvMapPwScraper
|
|
from app.utils.prompts.prompts import marketing_prompt
|
|
from app.utils.address_parser import extract_region_from_address
|
|
from app.utils.autotag import autotag_images
|
|
from app.utils.image_filter import filter_marketing_images, assemble_images
|
|
from app.video.services.video import get_image_tags_by_task_id
|
|
from config import azure_blob_settings
|
|
|
|
logger = get_logger("home")
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
async def _continuation_image_upload_lock(
|
|
task_id: Optional[str] = Form(default=None),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> AsyncIterator[None]:
|
|
"""continuation 요청 전체를 task 단위로 직렬화하는 yield dependency입니다."""
|
|
# 인증 완료 자체가 lock 획득의 전제이며 endpoint와 dependency cache를 공유합니다.
|
|
del current_user
|
|
requested_task_id = task_id.strip() if task_id and task_id.strip() else None
|
|
if requested_task_id is None:
|
|
yield
|
|
return
|
|
|
|
normalized_task_id = _normalize_continuation_task_id(requested_task_id)
|
|
try:
|
|
async with image_upload_task_lock(normalized_task_id):
|
|
yield
|
|
except ImageUploadLockTimeoutError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="동일 이미지 작업이 처리 중입니다. 잠시 후 다시 시도해주세요.",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/search/accommodation",
|
|
summary="장소 자동완성 검색 (숙박/음식점 등)",
|
|
description="""
|
|
네이버 지역 검색 API를 이용한 장소 자동완성 검색입니다.
|
|
|
|
## 요청 파라미터
|
|
- **query**: 검색어 (필수)
|
|
- **category**: 카테고리
|
|
|
|
## 반환 정보
|
|
- **query**: 검색어
|
|
- **count**: 검색 결과 수 (최대 10개)
|
|
- **items**: 검색 결과 목록
|
|
- **title**: 장소명 (HTML 태그 포함 가능)
|
|
- **address**: 지번 주소
|
|
- **roadAddress**: 도로명 주소
|
|
""",
|
|
response_model=AccommodationSearchResponse,
|
|
responses={
|
|
200: {"description": "검색 성공", "model": AccommodationSearchResponse},
|
|
},
|
|
tags=["Search"],
|
|
)
|
|
async def search_accommodation(
|
|
query: str,
|
|
) -> AccommodationSearchResponse:
|
|
"""장소 자동완성 검색"""
|
|
results = await naver_search_client.search_accommodation(
|
|
query=query,
|
|
display=10,
|
|
)
|
|
|
|
items = [AccommodationSearchItem(**item) for item in results]
|
|
|
|
return AccommodationSearchResponse(
|
|
query=query,
|
|
count=len(items),
|
|
items=items,
|
|
)
|
|
|
|
|
|
def _extract_region_from_address(
|
|
road_address: str | None, jibun_address: str | None = None
|
|
) -> str:
|
|
return extract_region_from_address(road_address, jibun_address)
|
|
|
|
|
|
class _IndustryOutput(BaseModel):
|
|
industry: Literal[
|
|
"stay",
|
|
"restaurant",
|
|
"cafe",
|
|
"salon",
|
|
"clinic",
|
|
"fitness",
|
|
"academy",
|
|
"attraction",
|
|
"general",
|
|
]
|
|
|
|
|
|
async def _resolve_industry(category: str, customer_name: str = "") -> str:
|
|
"""업체를 통합 프롬프트의 9개 industry enum 중 하나로 AI 분류.
|
|
|
|
분류 근거는 카테고리를 우선 사용하고, 카테고리가 없으면 업체명으로 분류한다.
|
|
근거가 둘 다 없으면 빈 문자열 반환. API 장애 시 예외 전파(하위 분석도 동일 API 의존).
|
|
"""
|
|
if category:
|
|
basis_label, basis_value = "네이버 지도 카테고리", category
|
|
elif customer_name:
|
|
basis_label, basis_value = "업체명", customer_name
|
|
else:
|
|
return ""
|
|
chatgpt = ChatgptService()
|
|
prompt = (
|
|
f"{basis_label}: '{basis_value}'\n"
|
|
"위 정보를 바탕으로 이 업체를 다음 업종 중 가장 적합한 하나로 분류하세요: "
|
|
"stay(숙박/펜션/호텔), restaurant(음식점), cafe(카페/디저트), "
|
|
"salon(미용실/네일/뷰티), clinic(병원/의원/치과), fitness(헬스/필라테스/요가), "
|
|
"academy(학원/교습소), attraction(관광/체험/액티비티/축제/행사). "
|
|
"위 8개 중 어느 것에도 명확히 해당하지 않는 경우에만 general(기타/범용 업종)로 분류하세요. "
|
|
"유사한 업종이 있으면 general 대신 그 업종을 우선 선택하세요."
|
|
)
|
|
result = await chatgpt._call_pydantic_output_chat_completion(
|
|
prompt=prompt,
|
|
output_format=_IndustryOutput,
|
|
model="gpt-4o-mini",
|
|
img_url=None,
|
|
image_detail_high=False,
|
|
)
|
|
return result.industry
|
|
|
|
|
|
@router.post(
|
|
"/crawling",
|
|
summary="네이버 지도 크롤링",
|
|
description="""
|
|
네이버 지도 장소 URL을 입력받아 이미지 목록과 기본 정보를 크롤링합니다.
|
|
|
|
## 요청 필드
|
|
- **url**: 네이버 지도 장소 URL (필수)
|
|
|
|
## 반환 정보
|
|
- **image_list**: 장소 이미지 URL 목록
|
|
- **image_count**: 이미지 개수
|
|
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
|
""",
|
|
response_model=CrawlingResponse,
|
|
response_description="크롤링 결과",
|
|
responses={
|
|
200: {"description": "크롤링 성공", "model": CrawlingResponse},
|
|
400: {
|
|
"description": "잘못된 URL",
|
|
"model": ErrorResponse,
|
|
},
|
|
502: {
|
|
"description": "크롤링 실패",
|
|
"model": ErrorResponse,
|
|
},
|
|
},
|
|
tags=["Crawling"],
|
|
)
|
|
async def crawling(
|
|
request_body: CrawlingRequest, session: AsyncSession = Depends(get_session)
|
|
):
|
|
return await _crawling_logic(request_body.url, session)
|
|
|
|
|
|
@router.post(
|
|
"/autocomplete",
|
|
summary="네이버 자동완성 크롤링",
|
|
description="""
|
|
네이버 검색 API 정보를 활용하여 Place ID를 추출한 뒤 자동으로 크롤링합니다.
|
|
|
|
## 요청 필드
|
|
- **title**: 네이버 검색 API Place 결과물 title (필수)
|
|
- **address**: 네이버 검색 API Place 결과물 지번주소 (필수)
|
|
- **roadAddress**:네이버 검색 API Place 결과물 도로명주소
|
|
|
|
## 반환 정보
|
|
- **image_list**: 장소 이미지 URL 목록
|
|
- **image_count**: 이미지 개수
|
|
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
|
""",
|
|
response_model=CrawlingResponse,
|
|
response_description="크롤링 결과",
|
|
responses={
|
|
200: {"description": "크롤링 성공", "model": CrawlingResponse},
|
|
400: {
|
|
"description": "잘못된 URL",
|
|
"model": ErrorResponse,
|
|
},
|
|
502: {
|
|
"description": "크롤링 실패",
|
|
"model": ErrorResponse,
|
|
},
|
|
},
|
|
tags=["Crawling"],
|
|
)
|
|
async def autocomplete_crawling(
|
|
request_body: AutoCompleteRequest, session: AsyncSession = Depends(get_session)
|
|
):
|
|
url = await _autocomplete_logic(request_body.model_dump())
|
|
return await _crawling_logic(url, session)
|
|
|
|
|
|
async def _crawling_logic(url: str, session: AsyncSession):
|
|
request_start = time.perf_counter()
|
|
logger.info("[crawling] ========== START ==========")
|
|
logger.info(f"[crawling] URL: {url[:80]}...")
|
|
|
|
# ========== Step 1: 네이버 지도 크롤링 ==========
|
|
step1_start = time.perf_counter()
|
|
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
|
|
|
|
try:
|
|
scraper = NvMapScraper(url)
|
|
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)"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
|
)
|
|
except URLNotFoundException as e:
|
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
|
logger.error(
|
|
f"[crawling] Step 1 FAILED - 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Place ID를 확인할 수 없습니다. URL을 확인하세요. : {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.exception("[crawling] Step 1 상세 오류:")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="네이버 지도 크롤링 중 오류가 발생했습니다.",
|
|
)
|
|
|
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
|
logger.info(
|
|
f"[crawling] Step 1 완료 - 업체사진 {len(scraper.owner_images or [])}개, "
|
|
f"보충사진 {len(scraper.extra_photo_urls or [])}개 ({step1_elapsed:.1f}ms)"
|
|
)
|
|
|
|
# ========== Step 2: 정보 가공 (industry 선행 계산) ==========
|
|
step2_start = time.perf_counter()
|
|
logger.info("[crawling] Step 2: 정보 가공 시작...")
|
|
|
|
processed_info = None
|
|
marketing_analysis = None
|
|
|
|
if scraper.base_info:
|
|
road_address = scraper.base_info.get("roadAddress", "")
|
|
jibun_address = scraper.base_info.get("address", "")
|
|
customer_name = scraper.base_info.get("name", "")
|
|
category = scraper.base_info.get("category", "")
|
|
region = _extract_region_from_address(road_address, jibun_address)
|
|
try:
|
|
# industry는 마케팅 적합성 필터(Step 3)와 ProcessedInfo 양쪽에 필요하므로
|
|
# 이미지 필터링보다 먼저 계산한다.
|
|
industry = await _resolve_industry(category, customer_name)
|
|
except ChatGPTResponseError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"업종 분류 중 ChatGPT 오류가 발생했습니다: {e.error_message}",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"[crawling] Step 2 FAILED - 업종 분류 오류: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="업종 분류 중 오류가 발생했습니다.",
|
|
)
|
|
|
|
processed_info = ProcessedInfo(
|
|
customer_name=customer_name,
|
|
region=region,
|
|
detail_region_info=road_address or jibun_address or "",
|
|
industry=industry,
|
|
)
|
|
|
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
|
logger.info(
|
|
f"[crawling] Step 2 완료 - {customer_name}, {region}, "
|
|
f"category={category!r}, industry={industry!r} ({step2_elapsed:.1f}ms)"
|
|
)
|
|
|
|
# ========== Step 3: 이미지 마케팅 적합성 필터링 ==========
|
|
# 업체 사진이 SUPPLEMENT_THRESHOLD(30장) 이상이면 보충이 불필요하므로
|
|
# 방문자 사진 필터링(Gemini 호출) 자체를 건너뛴다.
|
|
step3_start = time.perf_counter()
|
|
owner_images = scraper.owner_images or []
|
|
extra_photo_urls = scraper.extra_photo_urls or []
|
|
|
|
if len(owner_images) >= NvMapScraper.SUPPLEMENT_THRESHOLD:
|
|
# 업체 제공 사진은 필터링 면제 대상이므로 MAX_IMAGES 상한 없이 수집분을 전부 사용한다
|
|
# (MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용.
|
|
# 수집 자체는 NvMapScraper.BIZ_MAX_PAGES가 상한이며 도달 시 scraper가 warning을 남긴다).
|
|
scraper.image_link_list = list(owner_images)
|
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
|
logger.info(
|
|
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}장 "
|
|
f"≥ {NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략, 업체 사진만 사용"
|
|
)
|
|
else:
|
|
try:
|
|
extra_pass_flags = await filter_marketing_images(
|
|
[img["original"] for img in extra_photo_urls], industry
|
|
)
|
|
except Exception:
|
|
# logger.error(f"[crawling] Step 3 FAILED - 이미지 필터링 중 오류: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="이미지 마케팅 적합성 필터링 중 오류가 발생했습니다.",
|
|
)
|
|
scraper.image_link_list = assemble_images(
|
|
owner_images,
|
|
extra_photo_urls,
|
|
extra_pass_flags,
|
|
NvMapScraper.MAX_IMAGES,
|
|
)
|
|
passed_count = sum(extra_pass_flags)
|
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
|
logger.info(
|
|
f"[crawling] Step 3 완료 - 방문자 사진 {len(extra_photo_urls)}장 중 "
|
|
f"{passed_count}장 통과 → 최종 이미지 {len(scraper.image_link_list)}장 "
|
|
f"({step3_elapsed:.1f}ms)"
|
|
)
|
|
if not scraper.image_link_list:
|
|
logger.warning(
|
|
"[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다."
|
|
)
|
|
|
|
# ========== Step 4: ChatGPT 마케팅 분석 ==========
|
|
step4_start = time.perf_counter()
|
|
logger.info("[crawling] Step 4: ChatGPT 마케팅 분석 시작...")
|
|
|
|
try:
|
|
# Step 4-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
|
|
chatgpt_service = ChatgptService()
|
|
# 메뉴 전달은 잠정 보류 — 사이드 메뉴(볶음밥·냉면 등)가 분석에 과도한 영향을 주는
|
|
# 문제로 프롬프트에서 {menu_info}를 제거함. 크롤링(scraper.menu_info)은 유지 중이므로
|
|
# 주력/사이드 구분 방안이 정리되면 아래 주석을 되살려 재전달할 것.
|
|
# menus = scraper.menu_info or []
|
|
# menus = sorted(menus, key=lambda m: not m.get("recommend"))[:15]
|
|
# menu_info = ", ".join(m["name"] for m in menus if m.get("name"))
|
|
top_keywords = (scraper.voted_keyword_stats or [])[:5]
|
|
input_marketing_data = {
|
|
"customer_name": customer_name,
|
|
"region": region,
|
|
"detail_region_info": road_address or "",
|
|
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
|
|
"category": category, # 네이버 지도 원본 카테고리 텍스트
|
|
"facility_info": scraper.facility_info or "",
|
|
"voted_keywords": ", ".join(kw["displayName"] for kw in top_keywords),
|
|
# "menu_info": menu_info, # 실제 판매 품목 (셀링포인트 구체화용)
|
|
}
|
|
|
|
# Step 4-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
|
|
marketing_analysis = await chatgpt_service.generate_structured_output(
|
|
marketing_prompt, input_marketing_data
|
|
)
|
|
|
|
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장)
|
|
marketing_intel = MarketingIntel(
|
|
place_id=scraper.place_id,
|
|
intel_result=marketing_analysis.model_dump(),
|
|
)
|
|
session.add(marketing_intel)
|
|
await session.commit()
|
|
await session.refresh(marketing_intel)
|
|
m_id = marketing_intel.id
|
|
logger.debug(
|
|
f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}"
|
|
)
|
|
|
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
|
logger.info(
|
|
f"[crawling] Step 4 완료 - 마케팅 분석 성공 ({step4_elapsed:.1f}ms)"
|
|
)
|
|
|
|
except ChatGPTResponseError as e:
|
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
|
logger.error(
|
|
f"[crawling] Step 4 FAILED - ChatGPT Error: status={e.status}, "
|
|
f"code={e.error_code}, message={e.error_message} ({step4_elapsed:.1f}ms)"
|
|
)
|
|
marketing_analysis = None
|
|
gpt_status = "failed"
|
|
|
|
except Exception as e:
|
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
|
logger.error(
|
|
f"[crawling] Step 4 FAILED - GPT 마케팅 분석 중 오류: {e} ({step4_elapsed:.1f}ms)"
|
|
)
|
|
logger.exception("[crawling] Step 4 상세 오류:")
|
|
marketing_analysis = None
|
|
gpt_status = "failed"
|
|
else:
|
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
|
logger.warning(
|
|
f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)"
|
|
)
|
|
|
|
# ========== 완료 ==========
|
|
total_elapsed = (time.perf_counter() - request_start) * 1000
|
|
logger.info("[crawling] ========== COMPLETE ==========")
|
|
logger.info(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
|
|
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():
|
|
logger.info(f"[crawling] - Step 3 (이미지 필터링): {step3_elapsed:.1f}ms")
|
|
if "step4_elapsed" in locals():
|
|
logger.info(f"[crawling] - Step 4 (GPT 분석): {step4_elapsed:.1f}ms")
|
|
|
|
return {
|
|
"status": gpt_status if "gpt_status" in locals() else "completed",
|
|
"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,
|
|
"m_id": m_id,
|
|
"industry": industry if "industry" in locals() else "",
|
|
}
|
|
|
|
|
|
@router.post(
|
|
"/marketing",
|
|
summary="업체명+주소 직접 입력 마케팅 분석",
|
|
description="""
|
|
네이버 크롤링 없이 업체명과 주소를 직접 입력받아 마케팅 분석을 수행합니다.
|
|
|
|
## 요청 필드
|
|
- **customer_name**: 업체명 / 브랜드명 (필수)
|
|
- **address**: 도로명 또는 지번 주소 (필수)
|
|
- **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류
|
|
|
|
## 반환 정보
|
|
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
|
- **marketing_analysis**: ChatGPT 마케팅 분석 결과
|
|
- **m_id**: 마케팅 분석 결과 ID (이후 영상생성 파이프라인에 사용)
|
|
""",
|
|
response_model=CrawlingResponse,
|
|
tags=["Marketing"],
|
|
)
|
|
async def manual_marketing(
|
|
request_body: ManualMarketingRequest,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
# Step 1: 주소에서 지역명 추출 및 processed_info 구성
|
|
region = _extract_region_from_address(request_body.address)
|
|
processed_info = ProcessedInfo(
|
|
customer_name=request_body.store_name,
|
|
region=region,
|
|
detail_region_info=request_body.address,
|
|
)
|
|
try:
|
|
# Step 2: GPT API 호출 → 마케팅 분석 결과 생성
|
|
# place_id 없이 업체명+주소만으로 분석 (크롤링 없이 직접 입력된 경우)
|
|
chatgpt_service = ChatgptService()
|
|
# 크롤링 경로와 동일하게 category를 우선 사용하고, 없으면 업체명 기반 AI 분류
|
|
industry = await _resolve_industry(
|
|
request_body.category, request_body.store_name
|
|
)
|
|
processed_info.industry = industry
|
|
input_marketing_data = {
|
|
"customer_name": request_body.store_name,
|
|
"region": region,
|
|
"detail_region_info": request_body.address,
|
|
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
|
|
"category": request_body.category, # 사용자 입력 원본 카테고리 텍스트 (세부 묘사 참고용)
|
|
}
|
|
marketing_analysis = await chatgpt_service.generate_structured_output(
|
|
marketing_prompt, input_marketing_data
|
|
)
|
|
|
|
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
|
|
marketing_intel = MarketingIntel(
|
|
place_id=None,
|
|
intel_result=marketing_analysis.model_dump(),
|
|
)
|
|
session.add(marketing_intel)
|
|
await session.commit()
|
|
await session.refresh(marketing_intel)
|
|
m_id = marketing_intel.id
|
|
logger.debug(f"[MarketingPrompt] INSERT id={marketing_intel.id}")
|
|
except ChatGPTResponseError as e:
|
|
logger.error(
|
|
f"[marketing] ChatGPT Error: status={e.status}, "
|
|
f"code={e.error_code}, message={e.error_message}"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"마케팅 분석 중 ChatGPT 오류가 발생했습니다: {e.error_message}",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"[marketing] 마케팅 분석 중 오류: {e}")
|
|
logger.exception("[marketing] 상세 오류:")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="마케팅 분석 중 오류가 발생했습니다.",
|
|
)
|
|
return CrawlingResponse(
|
|
status="completed",
|
|
processed_info=processed_info,
|
|
marketing_analysis=marketing_analysis,
|
|
m_id=m_id,
|
|
industry=industry,
|
|
)
|
|
|
|
|
|
async def _autocomplete_logic(autocomplete_item: dict):
|
|
step1_start = time.perf_counter()
|
|
try:
|
|
async with NvMapPwScraper() as pw_scraper:
|
|
new_url = await pw_scraper.get_place_id_url(autocomplete_item)
|
|
except Exception as e:
|
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
|
logger.error(
|
|
f"[crawling] Autocomplete FAILED - 자동완성 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)"
|
|
)
|
|
logger.exception("[crawling] Autocomplete 상세 오류:")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="자동완성 place id 추출 실패",
|
|
)
|
|
|
|
if not new_url:
|
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
|
logger.error(
|
|
f"[crawling] Autocomplete FAILED - URL을 찾을 수 없음 ({step1_elapsed:.1f}ms)"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="해당 장소의 네이버 지도 URL을 찾을 수 없습니다.",
|
|
)
|
|
|
|
return new_url
|
|
|
|
|
|
def _extract_image_name(url: str, index: int) -> str:
|
|
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
|
try:
|
|
path = urlparse(url).path
|
|
filename = path.split("/")[-1] if path else ""
|
|
if filename:
|
|
return unquote(filename)
|
|
except Exception:
|
|
pass
|
|
return f"image_{index + 1:03d}"
|
|
|
|
|
|
IMAGES_JSON_EXAMPLE = """[
|
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
|
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
|
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"},
|
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"},
|
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
|
]"""
|
|
|
|
|
|
@router.post(
|
|
"/image/upload/blob",
|
|
summary="이미지 업로드 (Azure Blob Storage)",
|
|
description="""
|
|
이미지를 Azure Blob Storage에 업로드하고 task_id를 생성하거나 기존 작업에 이어 붙입니다.
|
|
바이너리 파일은 로컬 서버 경로에 복사하지 않고 Azure Blob에 청크 업로드됩니다.
|
|
|
|
## 인증
|
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
|
|
## 요청 방식
|
|
multipart/form-data 형식으로 전송합니다.
|
|
|
|
## 요청 필드
|
|
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
|
|
- **files**: 이미지 바이너리 파일 목록 (선택)
|
|
- **task_id**: 분할 업로드를 이어갈 기존 task_id (선택, UUID7)
|
|
- **finalize**: 누적 이미지 태깅 실행 여부 (기본값 true)
|
|
|
|
**주의**:
|
|
- 기존 단일 요청은 `finalize=true` 기본값으로 이전과 동일하게 동작합니다.
|
|
- 분할 업로드 첫 요청은 `finalize=false`와 최소 1개 파일을 보내고, 응답 task_id를 다음 요청에 전달합니다.
|
|
- 중간 요청은 `task_id`와 `finalize=false`, 마지막 요청만 `finalize=true`로 보냅니다.
|
|
- 파일 1개는 최대 15 MiB, 한 요청의 파일 합계는 최대 20 MiB입니다.
|
|
- 한 task에는 기본 최대 100개의 이미지를 누적할 수 있습니다(서버 설정 가능).
|
|
- `finalize=true`일 때 해당 task_id에 누적된 전체 이미지를 한 번에 태깅합니다.
|
|
|
|
## 지원 이미지 확장자
|
|
jpg, jpeg, png, webp, heic, heif
|
|
|
|
## images_json 예시
|
|
```json
|
|
[
|
|
{"url": "https://example.com/image1.jpg"},
|
|
{"url": "https://example.com/image2.jpg", "name": "외관"}
|
|
]
|
|
```
|
|
|
|
## 바이너리 파일 업로드 테스트 방법
|
|
|
|
### cURL로 테스트
|
|
```bash
|
|
# 바이너리 파일만 업로드
|
|
curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|
-H "Authorization: Bearer {access_token}" \\
|
|
-F "files=@/path/to/image1.jpg" \\
|
|
-F "files=@/path/to/image2.png"
|
|
|
|
# URL + 바이너리 파일 동시 업로드
|
|
curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|
-H "Authorization: Bearer {access_token}" \\
|
|
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
|
-F "files=@/path/to/local_image.jpg"
|
|
|
|
# 분할 업로드 첫 요청 (응답의 task_id 보관)
|
|
curl -X POST "http://localhost:8000/image/upload/blob" \
|
|
-H "Authorization: Bearer {access_token}" \
|
|
-F "files=@/path/to/image1.jpg" \
|
|
-F "finalize=false"
|
|
|
|
# 분할 업로드 마지막 요청
|
|
curl -X POST "http://localhost:8000/image/upload/blob" \
|
|
-H "Authorization: Bearer {access_token}" \
|
|
-F "task_id={task_id}" \
|
|
-F "files=@/path/to/image2.jpg" \
|
|
-F "finalize=true"
|
|
```
|
|
|
|
## 반환 정보
|
|
- **task_id**: 새로 생성된 작업 고유 식별자
|
|
- **total_count**: 총 업로드된 이미지 개수
|
|
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
|
|
- **file_count**: 파일로 업로드된 이미지 개수 (Azure Blob Storage에 저장)
|
|
- **saved_count**: Image 테이블에 저장된 row 수
|
|
- **images**: 업로드된 이미지 목록
|
|
- **source**: "url" (외부 URL) 또는 "blob" (Azure Blob Storage)
|
|
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
|
|
|
## 저장 경로
|
|
- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{user_uuid}/{task_id}/image/{파일명})
|
|
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
|
""",
|
|
response_model=ImageUploadResponse,
|
|
responses={
|
|
200: {"description": "이미지 업로드 성공"},
|
|
400: {"description": "입력 이미지가 유효하지 않음", "model": ErrorResponse},
|
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
403: {"description": "continuation task 소유권 검증 실패"},
|
|
413: {"description": "파일 또는 요청 크기 제한 초과"},
|
|
502: {"description": "Azure Blob 업로드 실패"},
|
|
},
|
|
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(
|
|
default=None,
|
|
description="외부 이미지 URL 목록 (JSON 문자열)",
|
|
examples=[IMAGES_JSON_EXAMPLE],
|
|
),
|
|
files: Optional[list[UploadFile]] = File(
|
|
default=None,
|
|
description="이미지 바이너리 파일 목록",
|
|
),
|
|
task_id: Optional[str] = Form(
|
|
default=None,
|
|
description="분할 업로드를 이어갈 기존 task_id (UUID7)",
|
|
),
|
|
finalize: bool = Form(
|
|
default=True,
|
|
description="true일 때 누적 이미지 태깅을 실행해 업로드를 완료",
|
|
),
|
|
industry: str = Form(
|
|
default="",
|
|
description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 크롤링 응답의 industry 값을 그대로 전달",
|
|
),
|
|
current_user: User = Depends(get_current_user),
|
|
_upload_lock: None = Depends(_continuation_image_upload_lock),
|
|
) -> ImageUploadResponse:
|
|
"""이미지 업로드 (URL + Azure Blob Storage)
|
|
|
|
3단계로 분리하여 세션 점유 시간 최소화:
|
|
- Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음)
|
|
- Stage 2: Azure Blob 업로드 (세션 없음)
|
|
- Stage 3: DB 저장 (새 세션으로 빠르게 처리)
|
|
"""
|
|
del _upload_lock
|
|
request_start = time.perf_counter()
|
|
requested_task_id = task_id.strip() if task_id and task_id.strip() else None
|
|
is_continuation = requested_task_id is not None
|
|
|
|
if requested_task_id:
|
|
task_id = _normalize_continuation_task_id(requested_task_id)
|
|
async with AsyncSessionLocal() as session:
|
|
existing_result = await session.execute(
|
|
select(Image)
|
|
.where(
|
|
Image.task_id == task_id,
|
|
Image.is_deleted.is_(False),
|
|
)
|
|
.order_by(Image.img_order, Image.id)
|
|
)
|
|
existing_images = list(existing_result.scalars().all())
|
|
_assert_continuation_owner(
|
|
existing_images,
|
|
current_user.user_uuid,
|
|
task_id,
|
|
)
|
|
else:
|
|
task_id = await generate_task_id()
|
|
existing_images = []
|
|
|
|
logger.info(
|
|
f"[upload_images_blob] START - task_id: {task_id}, "
|
|
f"continuation: {is_continuation}, finalize: {finalize}"
|
|
)
|
|
|
|
# ========== Stage 1: 입력 검증 (세션 없음, 파일 전체 메모리 적재 없음) ==========
|
|
has_images_json = images_json is not None and images_json.strip() != ""
|
|
has_files = files is not None and len(files) > 0
|
|
if not has_images_json and not has_files and not (is_continuation and finalize):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
|
)
|
|
|
|
url_images: list[ImageUrlItem] = []
|
|
if has_images_json and images_json:
|
|
try:
|
|
parsed = json.loads(images_json)
|
|
if not isinstance(parsed, list):
|
|
raise ValueError("JSON 최상위 값은 배열이어야 합니다.")
|
|
url_images = [ImageUrlItem(**item) for item in parsed if item]
|
|
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"images_json 파싱 오류: {str(e)}",
|
|
)
|
|
|
|
upload_files = files or []
|
|
declared_total_size = sum(
|
|
file.size for file in upload_files if file.size is not None and file.size > 0
|
|
)
|
|
max_request_size = azure_blob_settings.IMAGE_UPLOAD_MAX_REQUEST_SIZE_BYTES
|
|
if declared_total_size > max_request_size:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
detail=(
|
|
"한 요청의 파일 합계가 최대 크기 "
|
|
f"{max_request_size // (1024 * 1024)} MiB를 초과합니다."
|
|
),
|
|
)
|
|
|
|
valid_files_data: list[tuple[UploadFile, str, str, int]] = []
|
|
skipped_files: list[str] = []
|
|
actual_total_size = 0
|
|
for file in upload_files:
|
|
is_real_file = bool(file.filename and file.filename != "filename")
|
|
if not is_real_file or not _is_valid_image_extension(file.filename):
|
|
skipped_files.append(file.filename or "unknown")
|
|
continue
|
|
|
|
original_name, extension, actual_size = await _inspect_upload_file(file)
|
|
actual_total_size += actual_size
|
|
if actual_total_size > max_request_size:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
detail=(
|
|
"한 요청의 실제 파일 합계가 최대 크기 "
|
|
f"{max_request_size // (1024 * 1024)} MiB를 초과합니다."
|
|
),
|
|
)
|
|
valid_files_data.append((file, original_name, extension, actual_size))
|
|
|
|
provided_new_input = has_images_json or has_files
|
|
if provided_new_input and not url_images and not valid_files_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"유효한 이미지가 없습니다. "
|
|
f"지원 확장자: {', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))}. "
|
|
f"건너뛴 파일: {skipped_files}"
|
|
),
|
|
)
|
|
if not is_continuation and not finalize and not valid_files_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="분할 업로드의 첫 요청에는 소유권 확인용 이미지 파일이 필요합니다.",
|
|
)
|
|
|
|
validate_task_image_count(
|
|
existing_count=len(existing_images),
|
|
incoming_count=len(url_images) + len(valid_files_data),
|
|
)
|
|
|
|
stage1_time = time.perf_counter()
|
|
logger.info(
|
|
f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
|
f"files: {len(valid_files_data)}, bytes: {actual_total_size}, "
|
|
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
|
)
|
|
|
|
# ========== Stage 2: Azure Blob 청크 업로드 (세션 없음) ==========
|
|
# (원본명, 공개 URL, Azure 저장 파일명)
|
|
blob_upload_results: list[tuple[str, str, str]] = []
|
|
uploader: AzureBlobUploader | None = None
|
|
order_hint = max((image.img_order for image in existing_images), default=-1) + 1
|
|
order_hint += len(url_images)
|
|
|
|
async def cleanup_current_request_blobs() -> None:
|
|
if uploader is None:
|
|
return
|
|
for _, _, stored_name in blob_upload_results:
|
|
await uploader.delete_image(stored_name)
|
|
|
|
async def compensate_failed_db_write(
|
|
commit_started: bool,
|
|
error: BaseException,
|
|
) -> None:
|
|
"""DB 반영 여부가 불명확하면 Blob 보존을 우선합니다."""
|
|
if not blob_upload_results:
|
|
return
|
|
|
|
blob_urls = {blob_url for _, blob_url, _ in blob_upload_results}
|
|
reference_state = await compensate_failed_upload_blobs(
|
|
task_id=task_id,
|
|
blob_urls=blob_urls,
|
|
commit_started=commit_started,
|
|
cleanup=cleanup_current_request_blobs,
|
|
)
|
|
if not commit_started:
|
|
return
|
|
if reference_state == BlobReferenceState.NONE:
|
|
logger.warning(
|
|
f"[upload_images_blob] Commit failed and independent DB check "
|
|
f"confirmed no Blob references; cleaning up - task_id: {task_id}"
|
|
)
|
|
return
|
|
|
|
logger.error(
|
|
f"[upload_images_blob] Preserving Blob after ambiguous commit - "
|
|
f"task_id: {task_id}, reference_state: {reference_state}, "
|
|
f"error: {type(error).__name__}: {error}"
|
|
)
|
|
|
|
async def compensate_after_cancellation(
|
|
commit_started: bool,
|
|
error: asyncio.CancelledError,
|
|
) -> None:
|
|
compensation_task = asyncio.create_task(
|
|
compensate_failed_db_write(commit_started, error)
|
|
)
|
|
try:
|
|
await asyncio.shield(compensation_task)
|
|
except asyncio.CancelledError:
|
|
# 반복 취소가 와도 확인/정리가 끝나도록 하며, 실패 시에는 Blob을 보존합니다.
|
|
try:
|
|
await compensation_task
|
|
except BaseException as compensation_error:
|
|
logger.error(
|
|
f"[upload_images_blob] Cancellation compensation failed; "
|
|
f"preserving Blob - task_id: {task_id}, "
|
|
f"{type(compensation_error).__name__}: {compensation_error}"
|
|
)
|
|
|
|
if valid_files_data:
|
|
uploader = AzureBlobUploader(user_uuid=current_user.user_uuid, task_id=task_id)
|
|
total_files = len(valid_files_data)
|
|
|
|
for idx, (file, original_name, extension, actual_size) in enumerate(
|
|
valid_files_data
|
|
):
|
|
name_without_ext = Path(original_name).stem
|
|
unique_suffix = secrets.token_hex(4)
|
|
stored_name = (
|
|
f"{name_without_ext}_{order_hint + idx:03d}_{unique_suffix}{extension}"
|
|
)
|
|
logger.debug(
|
|
f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: "
|
|
f"{stored_name} ({actual_size} bytes)"
|
|
)
|
|
|
|
try:
|
|
upload_success = await uploader.upload_image_stream(
|
|
file,
|
|
stored_name,
|
|
expected_size_bytes=actual_size,
|
|
max_size_bytes=(
|
|
azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES
|
|
),
|
|
)
|
|
except BlobUploadTooLargeError as exc:
|
|
await cleanup_current_request_blobs()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
detail=(
|
|
f"파일 '{original_name}'이 최대 크기 "
|
|
f"{exc.max_size_bytes // (1024 * 1024)} MiB를 초과합니다."
|
|
),
|
|
)
|
|
except asyncio.CancelledError:
|
|
await asyncio.shield(cleanup_current_request_blobs())
|
|
raise
|
|
|
|
if upload_success:
|
|
blob_upload_results.append(
|
|
(original_name, uploader.public_url, stored_name)
|
|
)
|
|
logger.debug(
|
|
f"[upload_images_blob] File {idx + 1}/{total_files} SUCCESS"
|
|
)
|
|
else:
|
|
skipped_files.append(stored_name)
|
|
logger.warning(
|
|
f"[upload_images_blob] File {idx + 1}/{total_files} FAILED"
|
|
)
|
|
|
|
if valid_files_data and not blob_upload_results and not url_images:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="Azure Blob Storage 이미지 업로드에 실패했습니다.",
|
|
)
|
|
if not is_continuation and not finalize and not blob_upload_results:
|
|
# URL row만 남으면 다음 요청에서 소유권을 증명할 수 없으므로 저장하지 않습니다.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="분할 업로드를 시작할 이미지 파일 업로드에 실패했습니다.",
|
|
)
|
|
|
|
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"
|
|
)
|
|
|
|
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
|
|
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
|
|
all_images: list[Image] = []
|
|
commit_started = False
|
|
|
|
try:
|
|
async with AsyncSessionLocal() as session:
|
|
# 같은 task의 append 요청을 직렬화해 img_order 충돌을 줄입니다.
|
|
locked_result = await session.execute(
|
|
select(Image)
|
|
.where(
|
|
Image.task_id == task_id,
|
|
Image.is_deleted.is_(False),
|
|
)
|
|
.order_by(Image.img_order, Image.id)
|
|
.with_for_update()
|
|
)
|
|
locked_images = list(locked_result.scalars().all())
|
|
if is_continuation:
|
|
_assert_continuation_owner(
|
|
locked_images,
|
|
current_user.user_uuid,
|
|
task_id,
|
|
)
|
|
elif locked_images:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="이미 사용 중인 task_id가 생성되었습니다. 다시 시도해주세요.",
|
|
)
|
|
|
|
validate_task_image_count(
|
|
existing_count=len(locked_images),
|
|
incoming_count=len(url_images) + len(blob_upload_results),
|
|
)
|
|
|
|
img_order = (
|
|
max(
|
|
(image.img_order for image in locked_images),
|
|
default=-1,
|
|
)
|
|
+ 1
|
|
)
|
|
new_images: list[Image] = []
|
|
for url_item in url_images:
|
|
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
|
image = Image(
|
|
task_id=task_id,
|
|
img_name=img_name,
|
|
img_url=url_item.url,
|
|
img_order=img_order,
|
|
)
|
|
session.add(image)
|
|
new_images.append(image)
|
|
img_order += 1
|
|
|
|
for img_name, blob_url, _ in blob_upload_results:
|
|
image = Image(
|
|
task_id=task_id,
|
|
img_name=img_name,
|
|
img_url=blob_url,
|
|
img_order=img_order,
|
|
)
|
|
session.add(image)
|
|
new_images.append(image)
|
|
img_order += 1
|
|
|
|
await session.flush()
|
|
all_images = sorted(
|
|
[*locked_images, *new_images],
|
|
key=lambda image: (image.img_order, image.id),
|
|
)
|
|
commit_started = True
|
|
await session.commit()
|
|
stage3_time = time.perf_counter()
|
|
logger.info(
|
|
f"[upload_images_blob] Stage 3 done - "
|
|
f"task total: {len(all_images)}, added: {len(new_images)}, "
|
|
f"elapsed: {(stage3_time - stage2_time) * 1000:.1f}ms"
|
|
)
|
|
|
|
except asyncio.CancelledError as e:
|
|
await compensate_after_cancellation(commit_started, e)
|
|
raise
|
|
except HTTPException as e:
|
|
await compensate_failed_db_write(commit_started, e)
|
|
raise
|
|
except SQLAlchemyError as e:
|
|
await compensate_failed_db_write(commit_started, e)
|
|
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
|
|
logger.exception("[upload_images_blob] DB 상세 오류:")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
|
)
|
|
except Exception as e:
|
|
await compensate_failed_db_write(commit_started, 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,
|
|
detail="이미지 업로드 중 오류가 발생했습니다.",
|
|
)
|
|
|
|
result_images = [_image_result_item(image) for image in all_images]
|
|
saved_count = len(result_images)
|
|
image_urls = [img.img_url for img in result_images]
|
|
|
|
if finalize:
|
|
logger.info(f"[image_tagging] START - task_id: {task_id}")
|
|
await tagging_images(image_urls, industry=industry, clear_old_tags=True)
|
|
logger.info(f"[image_tagging] Done - task_id: {task_id}")
|
|
|
|
# 마지막 분할 요청에서 누적된 전체 이미지의 적합성을 확인합니다.
|
|
taged_image_list = await get_image_tags_by_task_id(task_id)
|
|
logger.info(f"태깅된 이미지: {len(taged_image_list)}개 - task_id: {task_id}")
|
|
if not taged_image_list:
|
|
logger.error(
|
|
f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"영상 생성에 적합한 이미지가 없습니다. "
|
|
"다른 이미지로 다시 업로드해주세요."
|
|
),
|
|
)
|
|
else:
|
|
logger.info(f"[image_tagging] DEFERRED - task_id: {task_id}")
|
|
|
|
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"
|
|
)
|
|
|
|
return ImageUploadResponse(
|
|
task_id=task_id,
|
|
total_count=saved_count,
|
|
url_count=sum(image.source == "url" for image in result_images),
|
|
file_count=sum(image.source == "blob" for image in result_images),
|
|
saved_count=saved_count,
|
|
images=result_images,
|
|
image_urls=image_urls,
|
|
)
|
|
|
|
|
|
async def tagging_images(
|
|
image_urls: list[str], industry: str = "", clear_old_tags: bool = False
|
|
) -> None:
|
|
# 1. 조회
|
|
async with AsyncSessionLocal() as session:
|
|
stmt = (
|
|
select(ImageTag)
|
|
.where(ImageTag.img_url_hash.in_([func.crc32(url) for url in image_urls]))
|
|
.where(ImageTag.img_url.in_(image_urls))
|
|
)
|
|
image_tags_query_results = await session.execute(stmt)
|
|
image_tags = image_tags_query_results.scalars().all()
|
|
existing_urls = {tag.img_url for tag in image_tags}
|
|
new_imt = [
|
|
ImageTag(img_url=url, img_tag=None)
|
|
for url in image_urls
|
|
if url not in existing_urls
|
|
]
|
|
if clear_old_tags:
|
|
for tag in image_tags:
|
|
tag.img_tag = None
|
|
session.add_all(new_imt)
|
|
null_imts = [imt for imt in image_tags if imt.img_tag is None] + new_imt
|
|
await session.commit()
|
|
|
|
if null_imts:
|
|
tag_datas = await autotag_images(
|
|
[img.img_url for img in null_imts], industry=industry
|
|
)
|
|
# print(tag_datas)
|
|
|
|
async with AsyncSessionLocal() as session:
|
|
for tag, tag_data in zip(null_imts, tag_datas):
|
|
if isinstance(tag_data, Exception):
|
|
# 태깅 실패 이미지는 img_tag가 NULL로 남아 영상 생성 풀에서 제외됨
|
|
logger.warning(
|
|
f"[tagging_images] 이미지 태깅 최종 실패 - url: {tag.img_url}, "
|
|
f"error: {type(tag_data).__name__}: {tag_data}"
|
|
)
|
|
continue
|
|
tag.img_tag = tag_data.model_dump(mode="json")
|
|
session.add(tag)
|
|
await session.commit()
|