o2o-castad-backend/app/home/api/routers/v1/home.py
hbyang 98c6515192 feat(marketing): 직접 입력 경로에 official_site_url 입력 지원
POST /marketing 요청에 official_site_url(선택, 기본 null)을 추가한다.
http/https 스킴만 허용하고 2048자 초과는 422로 거부하며, 값이 있으면
marketing.official_site_url에 저장돼 기존 영상 조회 응답에 그대로 노출된다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS
2026-08-21 10:57:42 +09:00

1188 lines
47 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()
# 요청당 1줄 요약 로그에 쓰이는 값들. 각 Step 이 실제로 실행될 때 채워진다.
customer_name = ""
region = ""
category = ""
industry = ""
owner_count = 0
extra_count = 0
filter_summary = "n/a"
gpt_status = "completed"
step2_elapsed = 0.0
step3_elapsed = 0.0
step4_elapsed = 0.0
# ========== Step 1: 네이버 지도 크롤링 ==========
step1_start = time.perf_counter()
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
owner_count = len(scraper.owner_images or [])
extra_count = len(scraper.extra_photo_urls or [])
# ========== Step 2: 정보 가공 (industry 선행 계산) ==========
step2_start = time.perf_counter()
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
# ========== 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
filter_summary = f"skip(owner>={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
filter_summary = f"{len(extra_photo_urls)}→{passed_count}"
if not scraper.image_link_list:
logger.warning(
"[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다."
)
# ========== Step 4: ChatGPT 마케팅 분석 ==========
step4_start = time.perf_counter()
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로 흐르므로 여기엔 미저장)
# 공식 링크: 플레이스 홈페이지 항목 우선, 없으면 유저가 입력한 크롤링 소스 URL
# (컬럼 길이를 넘는 긴 검색 URL은 place_id 기반 표준 플레이스 URL로 대체)
official_site_url = scraper.official_site_url or url
if len(official_site_url) > 2048:
official_site_url = (
f"https://map.naver.com/p/entry/place/{scraper.place_id[2:]}"
)
marketing_intel = MarketingIntel(
place_id=scraper.place_id,
official_site_url=official_site_url,
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
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(
f"[crawling] SUCCESS - url: {url[:80]}, name: {customer_name!r}, "
f"region: {region!r}, category: {category!r}, industry: {industry!r}, "
f"owner: {owner_count}, extra: {extra_count}, filter: {filter_summary}, "
f"images: {len(scraper.image_link_list or [])}, gpt: {gpt_status}, "
f"timing(ms): s1={step1_elapsed:.1f} s2={step2_elapsed:.1f} "
f"s3={step3_elapsed:.1f} s4={step4_elapsed:.1f} total={total_elapsed:.1f}"
)
return {
"status": gpt_status,
"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 분류
- **official_site_url**: 업체 공식 홈페이지 링크 (선택, http/https만 허용, 최대 2048자). 영상 응답의 official_site_url로 노출
## 반환 정보
- **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,
official_site_url=request_body.official_site_url,
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 = []
# ========== 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()
# ========== 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()
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
all_images: list[Image] = []
# 요약 로그용. 커밋이 끝난 뒤 실제 값으로 덮어쓴다.
stage3_time = stage2_time
added_count = 0
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()
added_count = len(new_images)
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]
tagging_summary = "deferred"
if finalize:
await tagging_images(image_urls, industry=industry, clear_old_tags=True)
# 마지막 분할 요청에서 누적된 전체 이미지의 적합성을 확인합니다.
taged_image_list = await get_image_tags_by_task_id(task_id)
tagging_summary = f"tagged={len(taged_image_list)}"
if not taged_image_list:
logger.error(
f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"영상 생성에 적합한 이미지가 없습니다. "
"다른 이미지로 다시 업로드해주세요."
),
)
total_time = time.perf_counter() - request_start
logger.info(
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
f"cont: {is_continuation}, finalize: {finalize}, "
f"urls: {len(url_images)}, files: {len(valid_files_data)}, "
f"bytes: {actual_total_size}, blobs: {len(blob_upload_results)}, "
f"skipped: {len(skipped_files)}, added: {added_count}, "
f"task_total: {saved_count}, tagging: {tagging_summary}, "
f"timing(ms): s1={(stage1_time - request_start) * 1000:.1f} "
f"blob={(stage2_time - stage1_time) * 1000:.1f} "
f"db={(stage3_time - stage2_time) * 1000:.1f} "
f"total={total_time * 1000:.1f}"
)
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()