Compare commits
No commits in common. "main" and "feature-meta" have entirely different histories.
main
...
feature-me
7
.gitignore
vendored
7
.gitignore
vendored
@ -32,11 +32,8 @@ media/
|
|||||||
|
|
||||||
|
|
||||||
*.ipynb_checkpoint*
|
*.ipynb_checkpoint*
|
||||||
# Static files (공유 기본 이미지는 예외로 추적)
|
# Static files
|
||||||
static/*
|
static/
|
||||||
!static/images/
|
|
||||||
static/images/*
|
|
||||||
!static/images/ado2_image.png
|
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
63
README.md
63
README.md
@ -69,9 +69,6 @@ PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포
|
|||||||
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
||||||
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
||||||
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
||||||
SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 페이지 → 영상 상세 이동 프론트 URL (로컬: http://localhost:3000, 테스트: https://dev.castad.net)
|
|
||||||
SHARE_API_BASE_URL= # 공유 OG 페이지의 외부 공개 API URL (예: https://dev-ssul.castad.net/api). 프록시가 /api 를 떼면 필수
|
|
||||||
SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 OG 이미지 (비우면 API /static/images/ado2_image.png)
|
|
||||||
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
@ -179,66 +176,6 @@ fastapi dev main.py
|
|||||||
fastapi run main.py
|
fastapi run main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### 운영 업로드 및 메모리 한도
|
|
||||||
|
|
||||||
`POST /api/image/upload/blob`은 애플리케이션에서 파일당 15 MiB까지만 허용합니다.
|
|
||||||
운영 Nginx에서는 multipart 오버헤드를 고려해 이 엔드포인트의 요청 본문을
|
|
||||||
25 MiB로 제한합니다. 앱의 요청당 파일 합계 상한은 20 MiB이며, 나머지 5 MiB는
|
|
||||||
multipart 헤더와 `images_json`을 위한 여유입니다. 한 task에는 최대 100개
|
|
||||||
이미지만 누적할 수 있습니다. 200 MiB 이상의 요청을
|
|
||||||
허용하도록 Nginx 한도를 올리지 마세요. 프론트엔드는 이미지를 압축한 뒤 파일
|
|
||||||
한 개씩 전송해야 합니다.
|
|
||||||
|
|
||||||
운영 Nginx 설정은 이 저장소에서 관리되지 않으므로, 기존
|
|
||||||
`location = /api/image/upload/blob` 블록 안에서 다음 스니펫을 include합니다.
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
include /배포경로/deploy/nginx/ado2-image-upload-limit.conf;
|
|
||||||
```
|
|
||||||
|
|
||||||
기존 설정이 prefix location만 사용한다면 그 블록의 `proxy_pass` 및 헤더 설정을
|
|
||||||
그대로 유지한 채, exact location을 추가하고 동일한 프록시 설정을 적용해야
|
|
||||||
합니다. 반영 전후에 실제 로드된 설정과 문법을 확인합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nginx -T | grep -n -E 'server_name|image/upload/blob|client_max_body_size'
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
`proxy_request_buffering off`는 이 스니펫에 포함하지 않았습니다. 이 옵션만으로
|
|
||||||
FastAPI의 multipart 파싱이 Azure 청크 스트리밍으로 바뀌지는 않으며, 느린
|
|
||||||
클라이언트 연결이 애플리케이션을 직접 점유하는 시간이 늘어날 수 있습니다.
|
|
||||||
|
|
||||||
Compose로 API를 실행하는 서버에서는 리소스 override를 함께 적용합니다.
|
|
||||||
이 override는 API 포트를 기본적으로 `127.0.0.1:8000`에만 바인딩해 외부
|
|
||||||
클라이언트가 Nginx의 요청 크기 제한을 우회하지 못하게 합니다. 운영 Nginx가
|
|
||||||
별도 컨테이너라면 호스트 포트를 공개하는 대신 두 서비스를 같은 내부 Docker
|
|
||||||
네트워크에 연결하세요. 부득이하게 `APP_BIND_ADDRESS`를 바꿀 때도 방화벽에서
|
|
||||||
8000 포트의 외부 접근을 차단해야 합니다. `!override` 구문을 위해 Docker
|
|
||||||
Compose 2.24.4 이상이 필요합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml -f compose.resources.yaml config --quiet
|
|
||||||
docker compose -f docker-compose.yml -f compose.resources.yaml up -d --force-recreate app
|
|
||||||
docker inspect castad-app \
|
|
||||||
--format 'memory={{.HostConfig.Memory}} reservation={{.HostConfig.MemoryReservation}} swap={{.HostConfig.MemorySwap}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
기본값은 hard limit 2 GiB, reservation 512 MiB이며 추가 swap은 허용하지
|
|
||||||
않습니다. 호스트 용량과 실제 렌더링 부하를 측정한 뒤
|
|
||||||
`APP_MEMORY_LIMIT`/`APP_MEMORY_RESERVATION`으로 조정할 수 있습니다. 예를 들어
|
|
||||||
`APP_MEMORY_LIMIT=3g`를 설정하면 hard limit와 swap limit가 함께 3 GiB로
|
|
||||||
변경됩니다.
|
|
||||||
|
|
||||||
주의: 현재 저장소의 Dockerfile은 Uvicorn을 실행하지만 운영 로그 파일명에는
|
|
||||||
Gunicorn이 나타납니다. 운영 프로세스가 호스트의 systemd/Gunicorn으로 직접
|
|
||||||
실행 중이라면 이 Compose 제한은 적용되지 않습니다. 배포 전에 실제 실행
|
|
||||||
주체를 확인하고, Compose 컨테이너가 아니라면 Gunicorn을 loopback 또는 Unix
|
|
||||||
socket에만 bind하고 해당 서비스 관리자의 메모리 제한을 별도로 설정해야
|
|
||||||
합니다. 외부에서 앱 포트로 직접 접근할 수 있으면 Nginx의 25 MiB 제한을
|
|
||||||
우회할 수 있습니다.
|
|
||||||
|
|
||||||
## API 문서
|
## API 문서
|
||||||
|
|
||||||
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
||||||
|
|||||||
@ -16,13 +16,8 @@ from app.user.dependencies.auth import get_current_user
|
|||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.utils.upload_blob_as_request import to_playback_url
|
|
||||||
from app.comment.models import Comment
|
from app.comment.models import Comment
|
||||||
from app.database.like_cache import (
|
from app.database.like_cache import get_like_counts, mset_like_counts
|
||||||
bulk_is_user_liked,
|
|
||||||
get_like_counts,
|
|
||||||
mset_like_counts,
|
|
||||||
)
|
|
||||||
from app.video.models import Video, VideoReaction
|
from app.video.models import Video, VideoReaction
|
||||||
from app.video.schemas.video_schema import VideoListItem
|
from app.video.schemas.video_schema import VideoListItem
|
||||||
|
|
||||||
@ -154,24 +149,6 @@ async def get_videos(
|
|||||||
if vid not in db_found_ids:
|
if vid not in db_found_ids:
|
||||||
like_count_map[vid] = 0
|
like_count_map[vid] = 0
|
||||||
|
|
||||||
# is_liked_by_me: Redis user-set 기준, 캐시 미스 시 현재 사용자 상태만 DB 조회
|
|
||||||
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
|
|
||||||
needs_db_lookup = [
|
|
||||||
vid for vid, liked in raw_liked.items()
|
|
||||||
if liked is None and like_count_map.get(vid, 0) > 0
|
|
||||||
]
|
|
||||||
if needs_db_lookup:
|
|
||||||
liked_video_ids = set((await session.execute(
|
|
||||||
select(VideoReaction.video_id).where(
|
|
||||||
VideoReaction.video_id.in_(needs_db_lookup),
|
|
||||||
VideoReaction.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)).scalars().all())
|
|
||||||
for vid in needs_db_lookup:
|
|
||||||
raw_liked[vid] = vid in liked_video_ids
|
|
||||||
|
|
||||||
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
|
|
||||||
|
|
||||||
# VideoListItem으로 변환
|
# VideoListItem으로 변환
|
||||||
items = [
|
items = [
|
||||||
VideoListItem(
|
VideoListItem(
|
||||||
@ -179,15 +156,10 @@ async def get_videos(
|
|||||||
store_name=project.store_name,
|
store_name=project.store_name,
|
||||||
region=project.region,
|
region=project.region,
|
||||||
task_id=video.task_id,
|
task_id=video.task_id,
|
||||||
result_movie_url=to_playback_url(video.result_movie_url),
|
result_movie_url=video.result_movie_url,
|
||||||
poster_url=video.poster_url,
|
|
||||||
title=video.title,
|
|
||||||
description=video.description,
|
|
||||||
hashtags=video.hashtags,
|
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
like_count=like_count_map.get(video.id) or 0,
|
like_count=like_count_map.get(video.id) or 0,
|
||||||
comment_count=comment_count or 0,
|
comment_count=comment_count or 0,
|
||||||
is_liked_by_me=liked_map.get(video.id, False),
|
|
||||||
)
|
)
|
||||||
for video, project, comment_count in rows
|
for video, project, comment_count in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@ -46,7 +46,7 @@ router = APIRouter(prefix="/comment", tags=["Comment"])
|
|||||||
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
|
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보를 그대로 사용합니다 (클라이언트에서 지정 불가).
|
- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책).
|
||||||
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
|
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
|
||||||
""",
|
""",
|
||||||
response_model=CommentCreateResponse,
|
response_model=CommentCreateResponse,
|
||||||
@ -71,7 +71,7 @@ async def post_comment(
|
|||||||
session=session,
|
session=session,
|
||||||
video_id=video_id,
|
video_id=video_id,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
nickname=current_user.nickname,
|
nickname=body.nickname,
|
||||||
content=body.content,
|
content=body.content,
|
||||||
parent_id=body.parent_id,
|
parent_id=body.parent_id,
|
||||||
)
|
)
|
||||||
@ -79,7 +79,6 @@ async def post_comment(
|
|||||||
return CommentCreateResponse(
|
return CommentCreateResponse(
|
||||||
id=comment.id,
|
id=comment.id,
|
||||||
nickname=comment.nickname or "익명",
|
nickname=comment.nickname or "익명",
|
||||||
profile_image_url=current_user.profile_image_url,
|
|
||||||
parent_id=comment.parent_id,
|
parent_id=comment.parent_id,
|
||||||
content=comment.content,
|
content=comment.content,
|
||||||
created_at=comment.created_at,
|
created_at=comment.created_at,
|
||||||
@ -102,7 +101,7 @@ async def post_comment(
|
|||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
|
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
|
||||||
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보 기준이며, is_mine으로 본인 댓글 여부도 확인 가능합니다.
|
- 작성자 정보는 노출되지 않으며, is_mine으로 본인 댓글 여부만 확인 가능합니다.
|
||||||
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
|
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
|
||||||
""",
|
""",
|
||||||
response_model=PaginatedResponse[CommentItem],
|
response_model=PaginatedResponse[CommentItem],
|
||||||
|
|||||||
@ -17,8 +17,7 @@ class Comment(Base):
|
|||||||
|
|
||||||
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
||||||
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
||||||
작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며,
|
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책).
|
||||||
프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "comment"
|
__tablename__ = "comment"
|
||||||
@ -55,7 +54,7 @@ class Comment(Base):
|
|||||||
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
|
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
|
||||||
)
|
)
|
||||||
nickname: Mapped[Optional[str]] = mapped_column(
|
nickname: Mapped[Optional[str]] = mapped_column(
|
||||||
String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)"
|
String(50), nullable=True, comment="댓글 작성자 닉네임 (null이면 익명)"
|
||||||
)
|
)
|
||||||
content: Mapped[str] = mapped_column(
|
content: Mapped[str] = mapped_column(
|
||||||
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"
|
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
class CommentCreateRequest(BaseModel):
|
class CommentCreateRequest(BaseModel):
|
||||||
|
nickname: Optional[str] = Field(None, min_length=1, max_length=50, description="작성자 닉네임 (미입력 시 익명)")
|
||||||
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
|
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
|
||||||
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
|
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
|
||||||
|
|
||||||
@ -13,8 +14,7 @@ class ReplyItem(BaseModel):
|
|||||||
"""대댓글 응답"""
|
"""대댓글 응답"""
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
id: int = Field(..., description="댓글 고유 ID")
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
|
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
is_deleted: bool = Field(..., description="삭제 여부")
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
||||||
@ -25,8 +25,7 @@ class CommentItem(BaseModel):
|
|||||||
"""최상위 댓글 응답 — replies 포함"""
|
"""최상위 댓글 응답 — replies 포함"""
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
id: int = Field(..., description="댓글 고유 ID")
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
|
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
is_deleted: bool = Field(..., description="삭제 여부")
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
||||||
@ -36,8 +35,7 @@ class CommentItem(BaseModel):
|
|||||||
|
|
||||||
class CommentCreateResponse(BaseModel):
|
class CommentCreateResponse(BaseModel):
|
||||||
id: int = Field(..., description="생성된 댓글 고유 ID")
|
id: int = Field(..., description="생성된 댓글 고유 ID")
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필)")
|
|
||||||
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
|
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
|
||||||
content: str = Field(..., description="댓글 본문")
|
content: str = Field(..., description="댓글 본문")
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
created_at: datetime = Field(..., description="작성 일시")
|
||||||
|
|||||||
@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.comment.models import Comment
|
from app.comment.models import Comment
|
||||||
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
|
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
|
||||||
from app.user.models import User
|
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
@ -38,7 +37,6 @@ def _build_comment_items(
|
|||||||
parents: list,
|
parents: list,
|
||||||
replies_map: dict,
|
replies_map: dict,
|
||||||
current_user_uuid: Optional[str],
|
current_user_uuid: Optional[str],
|
||||||
profile_image_map: dict,
|
|
||||||
) -> List[CommentItem]:
|
) -> List[CommentItem]:
|
||||||
items = []
|
items = []
|
||||||
for c in parents:
|
for c in parents:
|
||||||
@ -47,7 +45,6 @@ def _build_comment_items(
|
|||||||
ReplyItem(
|
ReplyItem(
|
||||||
id=r.id,
|
id=r.id,
|
||||||
nickname=r.nickname or "익명",
|
nickname=r.nickname or "익명",
|
||||||
profile_image_url=profile_image_map.get(r.user_uuid),
|
|
||||||
content=None if r.is_deleted else r.content,
|
content=None if r.is_deleted else r.content,
|
||||||
is_deleted=r.is_deleted,
|
is_deleted=r.is_deleted,
|
||||||
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
|
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
|
||||||
@ -59,7 +56,6 @@ def _build_comment_items(
|
|||||||
CommentItem(
|
CommentItem(
|
||||||
id=c.id,
|
id=c.id,
|
||||||
nickname=c.nickname or "익명",
|
nickname=c.nickname or "익명",
|
||||||
profile_image_url=profile_image_map.get(c.user_uuid),
|
|
||||||
content=None if c.is_deleted else c.content,
|
content=None if c.is_deleted else c.content,
|
||||||
is_deleted=c.is_deleted,
|
is_deleted=c.is_deleted,
|
||||||
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
|
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
|
||||||
@ -74,7 +70,7 @@ async def create_comment(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
video_id: int,
|
video_id: int,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
nickname: Optional[str],
|
nickname: str,
|
||||||
content: str,
|
content: str,
|
||||||
parent_id: Optional[int],
|
parent_id: Optional[int],
|
||||||
) -> Comment:
|
) -> Comment:
|
||||||
@ -147,7 +143,6 @@ async def list_comments(
|
|||||||
parents = (await session.execute(parents_q)).scalars().all()
|
parents = (await session.execute(parents_q)).scalars().all()
|
||||||
|
|
||||||
replies_map: dict = defaultdict(list)
|
replies_map: dict = defaultdict(list)
|
||||||
replies: list = []
|
|
||||||
if parents:
|
if parents:
|
||||||
parent_ids = [c.id for c in parents]
|
parent_ids = [c.id for c in parents]
|
||||||
replies_q = (
|
replies_q = (
|
||||||
@ -162,16 +157,7 @@ async def list_comments(
|
|||||||
for r in replies:
|
for r in replies:
|
||||||
replies_map[r.parent_id].append(r)
|
replies_map[r.parent_id].append(r)
|
||||||
|
|
||||||
# 작성자 프로필 이미지는 스냅샷을 저장하지 않고, 응답 시 User 테이블을 조인해 최신값을 조회한다.
|
items = _build_comment_items(list(parents), replies_map, current_user_uuid)
|
||||||
user_uuids = {c.user_uuid for c in parents} | {r.user_uuid for r in replies}
|
|
||||||
profile_image_map: dict = {}
|
|
||||||
if user_uuids:
|
|
||||||
profile_q = select(User.user_uuid, User.profile_image_url).where(
|
|
||||||
User.user_uuid.in_(user_uuids)
|
|
||||||
)
|
|
||||||
profile_image_map = {uuid: url for uuid, url in (await session.execute(profile_q)).all()}
|
|
||||||
|
|
||||||
items = _build_comment_items(list(parents), replies_map, current_user_uuid, profile_image_map)
|
|
||||||
|
|
||||||
return PaginatedResponse.create(
|
return PaginatedResponse.create(
|
||||||
items=items,
|
items=items,
|
||||||
|
|||||||
@ -80,8 +80,7 @@ async def create_db_tables():
|
|||||||
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
|
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
|
||||||
from app.lyric.models import Lyric # noqa: F401
|
from app.lyric.models import Lyric # noqa: F401
|
||||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
from app.song.models import Song, SongTimestamp # noqa: F401
|
||||||
from app.video.models import Video, VideoReaction # noqa: F401
|
from app.video.models import Video # noqa: F401
|
||||||
from app.comment.models import Comment # noqa: F401
|
|
||||||
from app.sns.models import SNSUploadTask # noqa: F401
|
from app.sns.models import SNSUploadTask # noqa: F401
|
||||||
from app.social.models import SocialUpload # noqa: F401
|
from app.social.models import SocialUpload # noqa: F401
|
||||||
from app.dashboard.models import Dashboard # noqa: F401
|
from app.dashboard.models import Dashboard # noqa: F401
|
||||||
@ -99,8 +98,6 @@ async def create_db_tables():
|
|||||||
Song.__table__,
|
Song.__table__,
|
||||||
SongTimestamp.__table__,
|
SongTimestamp.__table__,
|
||||||
Video.__table__,
|
Video.__table__,
|
||||||
VideoReaction.__table__,
|
|
||||||
Comment.__table__,
|
|
||||||
SNSUploadTask.__table__,
|
SNSUploadTask.__table__,
|
||||||
SocialUpload.__table__,
|
SocialUpload.__table__,
|
||||||
MarketingIntel.__table__,
|
MarketingIntel.__table__,
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import secrets
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@ -26,29 +25,14 @@ from app.home.schemas.home_schema import (
|
|||||||
CrawlingResponse,
|
CrawlingResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
ImageUploadResponse,
|
ImageUploadResponse,
|
||||||
|
ImageUploadResultItem,
|
||||||
ImageUrlItem,
|
ImageUrlItem,
|
||||||
ManualMarketingRequest,
|
ManualMarketingRequest,
|
||||||
ProcessedInfo,
|
ProcessedInfo,
|
||||||
# MarketingAnalysis,
|
# MarketingAnalysis,
|
||||||
)
|
)
|
||||||
from app.home.services.naver_search import naver_search_client
|
from app.home.services.naver_search import naver_search_client
|
||||||
from app.home.services.image_upload import (
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
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.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
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
|
||||||
@ -59,36 +43,13 @@ from app.utils.address_parser import extract_region_from_address
|
|||||||
from app.utils.autotag import autotag_images
|
from app.utils.autotag import autotag_images
|
||||||
from app.utils.image_filter import filter_marketing_images, assemble_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 app.video.services.video import get_image_tags_by_task_id
|
||||||
from config import azure_blob_settings
|
from config import MEDIA_ROOT
|
||||||
|
|
||||||
logger = get_logger("home")
|
logger = get_logger("home")
|
||||||
|
|
||||||
router = APIRouter()
|
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(
|
@router.get(
|
||||||
"/search/accommodation",
|
"/search/accommodation",
|
||||||
summary="장소 자동완성 검색 (숙박/음식점 등)",
|
summary="장소 자동완성 검색 (숙박/음식점 등)",
|
||||||
@ -131,23 +92,13 @@ async def search_accommodation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_region_from_address(
|
def _extract_region_from_address(road_address: str | None, jibun_address: str | None = None) -> str:
|
||||||
road_address: str | None, jibun_address: str | None = None
|
|
||||||
) -> str:
|
|
||||||
return extract_region_from_address(road_address, jibun_address)
|
return extract_region_from_address(road_address, jibun_address)
|
||||||
|
|
||||||
|
|
||||||
class _IndustryOutput(BaseModel):
|
class _IndustryOutput(BaseModel):
|
||||||
industry: Literal[
|
industry: Literal[
|
||||||
"stay",
|
"stay", "restaurant", "cafe", "salon", "clinic", "fitness", "academy", "attraction", "general"
|
||||||
"restaurant",
|
|
||||||
"cafe",
|
|
||||||
"salon",
|
|
||||||
"clinic",
|
|
||||||
"fitness",
|
|
||||||
"academy",
|
|
||||||
"attraction",
|
|
||||||
"general",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -213,11 +164,10 @@ async def _resolve_industry(category: str, customer_name: str = "") -> str:
|
|||||||
tags=["Crawling"],
|
tags=["Crawling"],
|
||||||
)
|
)
|
||||||
async def crawling(
|
async def crawling(
|
||||||
request_body: CrawlingRequest, session: AsyncSession = Depends(get_session)
|
request_body: CrawlingRequest,
|
||||||
):
|
session: AsyncSession = Depends(get_session)):
|
||||||
return await _crawling_logic(request_body.url, session)
|
return await _crawling_logic(request_body.url, session)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/autocomplete",
|
"/autocomplete",
|
||||||
summary="네이버 자동완성 크롤링",
|
summary="네이버 자동완성 크롤링",
|
||||||
@ -250,30 +200,21 @@ async def crawling(
|
|||||||
tags=["Crawling"],
|
tags=["Crawling"],
|
||||||
)
|
)
|
||||||
async def autocomplete_crawling(
|
async def autocomplete_crawling(
|
||||||
request_body: AutoCompleteRequest, session: AsyncSession = Depends(get_session)
|
request_body: AutoCompleteRequest,
|
||||||
):
|
session: AsyncSession = Depends(get_session)):
|
||||||
url = await _autocomplete_logic(request_body.model_dump())
|
url = await _autocomplete_logic(request_body.model_dump())
|
||||||
return await _crawling_logic(url, session)
|
return await _crawling_logic(url, session)
|
||||||
|
|
||||||
|
async def _crawling_logic(
|
||||||
async def _crawling_logic(url: str, session: AsyncSession):
|
url:str,
|
||||||
|
session: AsyncSession):
|
||||||
request_start = time.perf_counter()
|
request_start = time.perf_counter()
|
||||||
|
logger.info("[crawling] ========== START ==========")
|
||||||
# 요청당 1줄 요약 로그에 쓰이는 값들. 각 Step 이 실제로 실행될 때 채워진다.
|
logger.info(f"[crawling] URL: {url[:80]}...")
|
||||||
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: 네이버 지도 크롤링 ==========
|
# ========== Step 1: 네이버 지도 크롤링 ==========
|
||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
|
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
scraper = NvMapScraper(url)
|
scraper = NvMapScraper(url)
|
||||||
@ -308,11 +249,14 @@ async def _crawling_logic(url: str, session: AsyncSession):
|
|||||||
)
|
)
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
owner_count = len(scraper.owner_images or [])
|
logger.info(
|
||||||
extra_count = len(scraper.extra_photo_urls or [])
|
f"[crawling] Step 1 완료 - 업체사진 {len(scraper.owner_images or [])}개, "
|
||||||
|
f"보충사진 {len(scraper.extra_photo_urls or [])}개 ({step1_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
# ========== Step 2: 정보 가공 (industry 선행 계산) ==========
|
# ========== Step 2: 정보 가공 (industry 선행 계산) ==========
|
||||||
step2_start = time.perf_counter()
|
step2_start = time.perf_counter()
|
||||||
|
logger.info("[crawling] Step 2: 정보 가공 시작...")
|
||||||
|
|
||||||
processed_info = None
|
processed_info = None
|
||||||
marketing_analysis = None
|
marketing_analysis = None
|
||||||
@ -347,6 +291,10 @@ async def _crawling_logic(url: str, session: AsyncSession):
|
|||||||
)
|
)
|
||||||
|
|
||||||
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}, "
|
||||||
|
f"category={category!r}, industry={industry!r} ({step2_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
# ========== Step 3: 이미지 마케팅 적합성 필터링 ==========
|
# ========== Step 3: 이미지 마케팅 적합성 필터링 ==========
|
||||||
# 업체 사진이 SUPPLEMENT_THRESHOLD(30장) 이상이면 보충이 불필요하므로
|
# 업체 사진이 SUPPLEMENT_THRESHOLD(30장) 이상이면 보충이 불필요하므로
|
||||||
@ -361,34 +309,37 @@ async def _crawling_logic(url: str, session: AsyncSession):
|
|||||||
# 수집 자체는 NvMapScraper.BIZ_MAX_PAGES가 상한이며 도달 시 scraper가 warning을 남긴다).
|
# 수집 자체는 NvMapScraper.BIZ_MAX_PAGES가 상한이며 도달 시 scraper가 warning을 남긴다).
|
||||||
scraper.image_link_list = list(owner_images)
|
scraper.image_link_list = list(owner_images)
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
filter_summary = f"skip(owner>={NvMapScraper.SUPPLEMENT_THRESHOLD})"
|
logger.info(
|
||||||
|
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}장 "
|
||||||
|
f"≥ {NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략, 업체 사진만 사용"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
extra_pass_flags = await filter_marketing_images(
|
extra_pass_flags = await filter_marketing_images(
|
||||||
[img["original"] for img in extra_photo_urls], industry
|
[img["original"] for img in extra_photo_urls], industry
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# logger.error(f"[crawling] Step 3 FAILED - 이미지 필터링 중 오류: {e}")
|
# logger.error(f"[crawling] Step 3 FAILED - 이미지 필터링 중 오류: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail="이미지 마케팅 적합성 필터링 중 오류가 발생했습니다.",
|
detail="이미지 마케팅 적합성 필터링 중 오류가 발생했습니다.",
|
||||||
)
|
)
|
||||||
scraper.image_link_list = assemble_images(
|
scraper.image_link_list = assemble_images(
|
||||||
owner_images,
|
owner_images, extra_photo_urls, extra_pass_flags, NvMapScraper.MAX_IMAGES
|
||||||
extra_photo_urls,
|
|
||||||
extra_pass_flags,
|
|
||||||
NvMapScraper.MAX_IMAGES,
|
|
||||||
)
|
)
|
||||||
passed_count = sum(extra_pass_flags)
|
passed_count = sum(extra_pass_flags)
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
filter_summary = f"{len(extra_photo_urls)}→{passed_count}"
|
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:
|
if not scraper.image_link_list:
|
||||||
logger.warning(
|
logger.warning("[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다.")
|
||||||
"[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다."
|
|
||||||
)
|
|
||||||
|
|
||||||
# ========== Step 4: ChatGPT 마케팅 분석 ==========
|
# ========== Step 4: ChatGPT 마케팅 분석 ==========
|
||||||
step4_start = time.perf_counter()
|
step4_start = time.perf_counter()
|
||||||
|
logger.info("[crawling] Step 4: ChatGPT 마케팅 분석 시작...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 4-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
|
# Step 4-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
|
||||||
@ -417,27 +368,20 @@ async def _crawling_logic(url: str, session: AsyncSession):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장)
|
# 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(
|
marketing_intel = MarketingIntel(
|
||||||
place_id=scraper.place_id,
|
place_id=scraper.place_id,
|
||||||
official_site_url=official_site_url,
|
|
||||||
intel_result=marketing_analysis.model_dump(),
|
intel_result=marketing_analysis.model_dump(),
|
||||||
)
|
)
|
||||||
session.add(marketing_intel)
|
session.add(marketing_intel)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(marketing_intel)
|
await session.refresh(marketing_intel)
|
||||||
m_id = marketing_intel.id
|
m_id = marketing_intel.id
|
||||||
logger.debug(
|
logger.debug(f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}")
|
||||||
f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
|
logger.info(
|
||||||
|
f"[crawling] Step 4 완료 - 마케팅 분석 성공 ({step4_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
|
||||||
except ChatGPTResponseError as e:
|
except ChatGPTResponseError as e:
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
@ -464,26 +408,28 @@ async def _crawling_logic(url: str, session: AsyncSession):
|
|||||||
|
|
||||||
# ========== 완료 ==========
|
# ========== 완료 ==========
|
||||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||||
logger.info(
|
logger.info("[crawling] ========== COMPLETE ==========")
|
||||||
f"[crawling] SUCCESS - url: {url[:80]}, name: {customer_name!r}, "
|
logger.info(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
|
||||||
f"region: {region!r}, category: {category!r}, industry: {industry!r}, "
|
logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
|
||||||
f"owner: {owner_count}, extra: {extra_count}, filter: {filter_summary}, "
|
if scraper.base_info:
|
||||||
f"images: {len(scraper.image_link_list or [])}, gpt: {gpt_status}, "
|
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
|
||||||
f"timing(ms): s1={step1_elapsed:.1f} s2={step2_elapsed:.1f} "
|
if "step3_elapsed" in locals():
|
||||||
f"s3={step3_elapsed:.1f} s4={step4_elapsed:.1f} total={total_elapsed:.1f}"
|
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 {
|
return {
|
||||||
"status": gpt_status,
|
"status": gpt_status if 'gpt_status' in locals() else "completed",
|
||||||
"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,
|
||||||
"m_id": m_id,
|
"m_id": m_id,
|
||||||
"industry": industry if "industry" in locals() else "",
|
"industry": industry if 'industry' in locals() else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/marketing",
|
"/marketing",
|
||||||
summary="업체명+주소 직접 입력 마케팅 분석",
|
summary="업체명+주소 직접 입력 마케팅 분석",
|
||||||
@ -494,7 +440,6 @@ async def _crawling_logic(url: str, session: AsyncSession):
|
|||||||
- **customer_name**: 업체명 / 브랜드명 (필수)
|
- **customer_name**: 업체명 / 브랜드명 (필수)
|
||||||
- **address**: 도로명 또는 지번 주소 (필수)
|
- **address**: 도로명 또는 지번 주소 (필수)
|
||||||
- **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류
|
- **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류
|
||||||
- **official_site_url**: 업체 공식 홈페이지 링크 (선택, http/https만 허용, 최대 2048자). 영상 응답의 official_site_url로 노출
|
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
||||||
@ -538,7 +483,6 @@ async def manual_marketing(
|
|||||||
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
|
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
|
||||||
marketing_intel = MarketingIntel(
|
marketing_intel = MarketingIntel(
|
||||||
place_id=None,
|
place_id=None,
|
||||||
official_site_url=request_body.official_site_url,
|
|
||||||
intel_result=marketing_analysis.model_dump(),
|
intel_result=marketing_analysis.model_dump(),
|
||||||
)
|
)
|
||||||
session.add(marketing_intel)
|
session.add(marketing_intel)
|
||||||
@ -571,7 +515,7 @@ async def manual_marketing(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _autocomplete_logic(autocomplete_item: dict):
|
async def _autocomplete_logic(autocomplete_item:dict):
|
||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
async with NvMapPwScraper() as pw_scraper:
|
async with NvMapPwScraper() as pw_scraper:
|
||||||
@ -599,7 +543,6 @@ async def _autocomplete_logic(autocomplete_item: dict):
|
|||||||
|
|
||||||
return new_url
|
return new_url
|
||||||
|
|
||||||
|
|
||||||
def _extract_image_name(url: str, index: int) -> str:
|
def _extract_image_name(url: str, index: int) -> str:
|
||||||
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
||||||
try:
|
try:
|
||||||
@ -612,6 +555,30 @@ def _extract_image_name(url: str, index: int) -> str:
|
|||||||
return f"image_{index + 1:03d}"
|
return f"image_{index + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_image_extension(filename: str | None) -> bool:
|
||||||
|
"""파일명의 확장자가 유효한 이미지 확장자인지 확인"""
|
||||||
|
if not filename:
|
||||||
|
return False
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
return ext in ALLOWED_IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _get_file_extension(filename: str) -> str:
|
||||||
|
"""파일명에서 확장자 추출 (소문자)"""
|
||||||
|
return Path(filename).suffix.lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_upload_file(file: UploadFile, save_path: Path) -> None:
|
||||||
|
"""업로드 파일을 지정된 경로에 저장"""
|
||||||
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with aiofiles.open(save_path, "wb") as f:
|
||||||
|
content = await file.read()
|
||||||
|
await f.write(content)
|
||||||
|
|
||||||
|
|
||||||
IMAGES_JSON_EXAMPLE = """[
|
IMAGES_JSON_EXAMPLE = """[
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
|
{"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_48/1715688030574wTtQd_JPEG/2.jpg"},
|
||||||
@ -620,13 +587,12 @@ IMAGES_JSON_EXAMPLE = """[
|
|||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
||||||
]"""
|
]"""
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/image/upload/blob",
|
"/image/upload/blob",
|
||||||
summary="이미지 업로드 (Azure Blob Storage)",
|
summary="이미지 업로드 (Azure Blob Storage)",
|
||||||
description="""
|
description="""
|
||||||
이미지를 Azure Blob Storage에 업로드하고 task_id를 생성하거나 기존 작업에 이어 붙입니다.
|
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
|
||||||
바이너리 파일은 로컬 서버 경로에 복사하지 않고 Azure Blob에 청크 업로드됩니다.
|
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
|
||||||
|
|
||||||
## 인증
|
## 인증
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||||
@ -637,16 +603,8 @@ multipart/form-data 형식으로 전송합니다.
|
|||||||
## 요청 필드
|
## 요청 필드
|
||||||
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
|
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
|
||||||
- **files**: 이미지 바이너리 파일 목록 (선택)
|
- **files**: 이미지 바이너리 파일 목록 (선택)
|
||||||
- **task_id**: 분할 업로드를 이어갈 기존 task_id (선택, UUID7)
|
|
||||||
- **finalize**: 누적 이미지 태깅 실행 여부 (기본값 true)
|
|
||||||
|
|
||||||
**주의**:
|
**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다.
|
||||||
- 기존 단일 요청은 `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
|
jpg, jpeg, png, webp, heic, heif
|
||||||
@ -674,19 +632,6 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|||||||
-H "Authorization: Bearer {access_token}" \\
|
-H "Authorization: Bearer {access_token}" \\
|
||||||
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
||||||
-F "files=@/path/to/local_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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
@ -700,17 +645,14 @@ curl -X POST "http://localhost:8000/image/upload/blob" \
|
|||||||
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
||||||
|
|
||||||
## 저장 경로
|
## 저장 경로
|
||||||
- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{user_uuid}/{task_id}/image/{파일명})
|
- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{task_id}/image/{파일명})
|
||||||
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
||||||
""",
|
""",
|
||||||
response_model=ImageUploadResponse,
|
response_model=ImageUploadResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "이미지 업로드 성공"},
|
200: {"description": "이미지 업로드 성공"},
|
||||||
400: {"description": "입력 이미지가 유효하지 않음", "model": ErrorResponse},
|
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
403: {"description": "continuation task 소유권 검증 실패"},
|
|
||||||
413: {"description": "파일 또는 요청 크기 제한 초과"},
|
|
||||||
502: {"description": "Azure Blob 업로드 실패"},
|
|
||||||
},
|
},
|
||||||
tags=["Image-Blob"],
|
tags=["Image-Blob"],
|
||||||
openapi_extra={
|
openapi_extra={
|
||||||
@ -733,20 +675,11 @@ async def upload_images_blob(
|
|||||||
default=None,
|
default=None,
|
||||||
description="이미지 바이너리 파일 목록",
|
description="이미지 바이너리 파일 목록",
|
||||||
),
|
),
|
||||||
task_id: Optional[str] = Form(
|
|
||||||
default=None,
|
|
||||||
description="분할 업로드를 이어갈 기존 task_id (UUID7)",
|
|
||||||
),
|
|
||||||
finalize: bool = Form(
|
|
||||||
default=True,
|
|
||||||
description="true일 때 누적 이미지 태깅을 실행해 업로드를 완료",
|
|
||||||
),
|
|
||||||
industry: str = Form(
|
industry: str = Form(
|
||||||
default="",
|
default="",
|
||||||
description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 크롤링 응답의 industry 값을 그대로 전달",
|
description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 크롤링 응답의 industry 값을 그대로 전달",
|
||||||
),
|
),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
_upload_lock: None = Depends(_continuation_image_upload_lock),
|
|
||||||
) -> ImageUploadResponse:
|
) -> ImageUploadResponse:
|
||||||
"""이미지 업로드 (URL + Azure Blob Storage)
|
"""이미지 업로드 (URL + Azure Blob Storage)
|
||||||
|
|
||||||
@ -755,288 +688,126 @@ async def upload_images_blob(
|
|||||||
- Stage 2: Azure Blob 업로드 (세션 없음)
|
- Stage 2: Azure Blob 업로드 (세션 없음)
|
||||||
- Stage 3: DB 저장 (새 세션으로 빠르게 처리)
|
- Stage 3: DB 저장 (새 세션으로 빠르게 처리)
|
||||||
"""
|
"""
|
||||||
del _upload_lock
|
|
||||||
request_start = time.perf_counter()
|
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 생성
|
||||||
task_id = _normalize_continuation_task_id(requested_task_id)
|
task_id = await generate_task_id()
|
||||||
async with AsyncSessionLocal() as session:
|
logger.info(f"[upload_images_blob] START - task_id: {task_id}")
|
||||||
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: 입력 검증 (세션 없음, 파일 전체 메모리 적재 없음) ==========
|
# ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ==========
|
||||||
has_images_json = images_json is not None and images_json.strip() != ""
|
has_images_json = images_json is not None and images_json.strip() != ""
|
||||||
has_files = files is not None and len(files) > 0
|
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):
|
|
||||||
|
if not has_images_json and not has_files:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# images_json 파싱
|
||||||
url_images: list[ImageUrlItem] = []
|
url_images: list[ImageUrlItem] = []
|
||||||
if has_images_json and images_json:
|
if has_images_json and images_json:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(images_json)
|
parsed = json.loads(images_json)
|
||||||
if not isinstance(parsed, list):
|
if isinstance(parsed, list):
|
||||||
raise ValueError("JSON 최상위 값은 배열이어야 합니다.")
|
url_images = [ImageUrlItem(**item) for item in parsed if item]
|
||||||
url_images = [ImageUrlItem(**item) for item in parsed if item]
|
|
||||||
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"images_json 파싱 오류: {str(e)}",
|
detail=f"images_json 파싱 오류: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
upload_files = files or []
|
# 유효한 파일만 필터링 및 파일 내용 미리 읽기
|
||||||
declared_total_size = sum(
|
valid_files_data: list[tuple[str, str, bytes]] = [] # (original_name, ext, content)
|
||||||
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] = []
|
skipped_files: list[str] = []
|
||||||
actual_total_size = 0
|
if has_files and files:
|
||||||
for file in upload_files:
|
for f in files:
|
||||||
is_real_file = bool(file.filename and file.filename != "filename")
|
is_valid_ext = _is_valid_image_extension(f.filename)
|
||||||
if not is_real_file or not _is_valid_image_extension(file.filename):
|
is_not_empty = f.size is None or f.size > 0
|
||||||
skipped_files.append(file.filename or "unknown")
|
is_real_file = f.filename and f.filename != "filename"
|
||||||
continue
|
|
||||||
|
|
||||||
original_name, extension, actual_size = await _inspect_upload_file(file)
|
if f and is_real_file and is_valid_ext and is_not_empty:
|
||||||
actual_total_size += actual_size
|
# 파일 내용을 미리 읽어둠
|
||||||
if actual_total_size > max_request_size:
|
content = await f.read()
|
||||||
raise HTTPException(
|
ext = _get_file_extension(f.filename) # type: ignore[arg-type]
|
||||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
valid_files_data.append((f.filename or "image", ext, content))
|
||||||
detail=(
|
else:
|
||||||
"한 요청의 실제 파일 합계가 최대 크기 "
|
skipped_files.append(f.filename or "unknown")
|
||||||
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 not url_images and not valid_files_data:
|
||||||
if provided_new_input and not url_images and not valid_files_data:
|
detail = (
|
||||||
|
f"유효한 이미지가 없습니다. "
|
||||||
|
f"지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. "
|
||||||
|
f"건너뛴 파일: {skipped_files}"
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=(
|
detail=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()
|
stage1_time = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
||||||
|
f"files: {len(valid_files_data)}, "
|
||||||
|
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
# ========== Stage 2: Azure Blob 청크 업로드 (세션 없음) ==========
|
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
|
||||||
# (원본명, 공개 URL, Azure 저장 파일명)
|
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
|
||||||
blob_upload_results: list[tuple[str, str, str]] = []
|
blob_upload_results: list[tuple[str, str]] = [] # (img_name, blob_url)
|
||||||
uploader: AzureBlobUploader | None = None
|
img_order = len(url_images) # URL 이미지 다음 순서부터 시작
|
||||||
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:
|
if valid_files_data:
|
||||||
uploader = AzureBlobUploader(user_uuid=current_user.user_uuid, task_id=task_id)
|
uploader = AzureBlobUploader(user_uuid=current_user.user_uuid, task_id=task_id)
|
||||||
total_files = len(valid_files_data)
|
total_files = len(valid_files_data)
|
||||||
|
|
||||||
for idx, (file, original_name, extension, actual_size) in enumerate(
|
for idx, (original_name, ext, file_content) in enumerate(valid_files_data):
|
||||||
valid_files_data
|
name_without_ext = (
|
||||||
):
|
original_name.rsplit(".", 1)[0]
|
||||||
name_without_ext = Path(original_name).stem
|
if "." in original_name
|
||||||
unique_suffix = secrets.token_hex(4)
|
else original_name
|
||||||
stored_name = (
|
|
||||||
f"{name_without_ext}_{order_hint + idx:03d}_{unique_suffix}{extension}"
|
|
||||||
)
|
)
|
||||||
|
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: "
|
f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: "
|
||||||
f"{stored_name} ({actual_size} bytes)"
|
f"{filename} ({len(file_content)} bytes)"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Azure Blob Storage에 직접 업로드
|
||||||
upload_success = await uploader.upload_image_stream(
|
upload_success = await uploader.upload_image_bytes(file_content, filename)
|
||||||
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:
|
if upload_success:
|
||||||
blob_upload_results.append(
|
blob_url = uploader.public_url
|
||||||
(original_name, uploader.public_url, stored_name)
|
blob_upload_results.append((original_name, blob_url))
|
||||||
)
|
img_order += 1
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[upload_images_blob] File {idx + 1}/{total_files} SUCCESS"
|
f"[upload_images_blob] File {idx + 1}/{total_files} SUCCESS"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
skipped_files.append(stored_name)
|
skipped_files.append(filename)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[upload_images_blob] File {idx + 1}/{total_files} FAILED"
|
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()
|
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 저장 (새 세션으로 빠르게 처리) ==========
|
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
|
||||||
all_images: list[Image] = []
|
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
|
||||||
# 요약 로그용. 커밋이 끝난 뒤 실제 값으로 덮어쓴다.
|
result_images: list[ImageUploadResultItem] = []
|
||||||
stage3_time = stage2_time
|
img_order = 0
|
||||||
added_count = 0
|
|
||||||
commit_started = False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# 같은 task의 append 요청을 직렬화해 img_order 충돌을 줄입니다.
|
# URL 이미지 저장
|
||||||
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:
|
for url_item in url_images:
|
||||||
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
||||||
|
|
||||||
image = Image(
|
image = Image(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
img_name=img_name,
|
img_name=img_name,
|
||||||
@ -1044,10 +815,21 @@ async def upload_images_blob(
|
|||||||
img_order=img_order,
|
img_order=img_order,
|
||||||
)
|
)
|
||||||
session.add(image)
|
session.add(image)
|
||||||
new_images.append(image)
|
await session.flush()
|
||||||
|
|
||||||
|
result_images.append(
|
||||||
|
ImageUploadResultItem(
|
||||||
|
id=image.id,
|
||||||
|
img_name=img_name,
|
||||||
|
img_url=url_item.url,
|
||||||
|
img_order=img_order,
|
||||||
|
source="url",
|
||||||
|
)
|
||||||
|
)
|
||||||
img_order += 1
|
img_order += 1
|
||||||
|
|
||||||
for img_name, blob_url, _ in blob_upload_results:
|
# Blob 업로드 결과 저장
|
||||||
|
for img_name, blob_url in blob_upload_results:
|
||||||
image = Image(
|
image = Image(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
img_name=img_name,
|
img_name=img_name,
|
||||||
@ -1055,27 +837,28 @@ async def upload_images_blob(
|
|||||||
img_order=img_order,
|
img_order=img_order,
|
||||||
)
|
)
|
||||||
session.add(image)
|
session.add(image)
|
||||||
new_images.append(image)
|
await session.flush()
|
||||||
|
|
||||||
|
result_images.append(
|
||||||
|
ImageUploadResultItem(
|
||||||
|
id=image.id,
|
||||||
|
img_name=img_name,
|
||||||
|
img_url=blob_url,
|
||||||
|
img_order=img_order,
|
||||||
|
source="blob",
|
||||||
|
)
|
||||||
|
)
|
||||||
img_order += 1
|
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()
|
await session.commit()
|
||||||
stage3_time = time.perf_counter()
|
stage3_time = time.perf_counter()
|
||||||
added_count = len(new_images)
|
logger.info(
|
||||||
|
f"[upload_images_blob] Stage 3 done - "
|
||||||
|
f"saved: {len(result_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:
|
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.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
|
||||||
logger.exception("[upload_images_blob] DB 상세 오류:")
|
logger.exception("[upload_images_blob] DB 상세 오류:")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -1083,7 +866,6 @@ async def upload_images_blob(
|
|||||||
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await compensate_failed_db_write(commit_started, e)
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[upload_images_blob] Stage 3 EXCEPTION - "
|
f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||||
@ -1094,48 +876,36 @@ async def upload_images_blob(
|
|||||||
detail="이미지 업로드 중 오류가 발생했습니다.",
|
detail="이미지 업로드 중 오류가 발생했습니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
result_images = [_image_result_item(image) for image in all_images]
|
|
||||||
saved_count = len(result_images)
|
saved_count = len(result_images)
|
||||||
image_urls = [img.img_url for img in result_images]
|
image_urls = [img.img_url for img in result_images]
|
||||||
|
|
||||||
tagging_summary = "deferred"
|
logger.info(f"[image_tagging] START - task_id: {task_id}")
|
||||||
if finalize:
|
await tagging_images(image_urls, industry=industry, clear_old_tags=True)
|
||||||
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)
|
# (여기서 걸러지지 않으면 훨씬 나중인 영상 생성 단계에서야 슬롯 미배정으로 발견됨)
|
||||||
tagging_summary = f"tagged={len(taged_image_list)}"
|
# marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되었으므로 여기서는 재필터링하지 않는다.
|
||||||
if not taged_image_list:
|
taged_image_list = await get_image_tags_by_task_id(task_id)
|
||||||
logger.error(
|
logger.info(f"태깅된 이미지: {len(taged_image_list)}개 - task_id: {task_id}")
|
||||||
f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}"
|
if not taged_image_list:
|
||||||
)
|
logger.error(f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=(
|
detail="영상 생성에 적합한 이미지가 없습니다. 다른 이미지로 다시 업로드해주세요.",
|
||||||
"영상 생성에 적합한 이미지가 없습니다. "
|
)
|
||||||
"다른 이미지로 다시 업로드해주세요."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
total_time = time.perf_counter() - request_start
|
total_time = time.perf_counter() - request_start
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||||
f"cont: {is_continuation}, finalize: {finalize}, "
|
f"total: {saved_count}, total_time: {total_time * 1000:.1f}ms"
|
||||||
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(
|
return ImageUploadResponse(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
total_count=saved_count,
|
total_count=len(result_images),
|
||||||
url_count=sum(image.source == "url" for image in result_images),
|
url_count=len(url_images),
|
||||||
file_count=sum(image.source == "blob" for image in result_images),
|
file_count=len(blob_upload_results),
|
||||||
saved_count=saved_count,
|
saved_count=saved_count,
|
||||||
images=result_images,
|
images=result_images,
|
||||||
image_urls=image_urls,
|
image_urls=image_urls,
|
||||||
@ -1143,8 +913,10 @@ async def upload_images_blob(
|
|||||||
|
|
||||||
|
|
||||||
async def tagging_images(
|
async def tagging_images(
|
||||||
image_urls: list[str], industry: str = "", clear_old_tags: bool = False
|
image_urls : list[str],
|
||||||
) -> None:
|
industry: str = "",
|
||||||
|
clear_old_tags : bool = False
|
||||||
|
) -> None:
|
||||||
# 1. 조회
|
# 1. 조회
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
stmt = (
|
stmt = (
|
||||||
@ -1168,9 +940,7 @@ async def tagging_images(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if null_imts:
|
if null_imts:
|
||||||
tag_datas = await autotag_images(
|
tag_datas = await autotag_images([img.img_url for img in null_imts], industry=industry)
|
||||||
[img.img_url for img in null_imts], industry=industry
|
|
||||||
)
|
|
||||||
# print(tag_datas)
|
# print(tag_datas)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
|
|||||||
@ -274,7 +274,6 @@ class MarketingIntel(Base):
|
|||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
place_id : 데이터 소스별 식별자
|
place_id : 데이터 소스별 식별자
|
||||||
official_site_url : 업체 공식 링크 (플레이스 홈페이지 항목, 없으면 크롤링 소스 URL)
|
|
||||||
intel_result : 마케팅 분석 결과물 json
|
intel_result : 마케팅 분석 결과물 json
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
"""
|
"""
|
||||||
@ -303,12 +302,6 @@ class MarketingIntel(Base):
|
|||||||
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
|
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
|
||||||
)
|
)
|
||||||
|
|
||||||
official_site_url: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(2048),
|
|
||||||
nullable=True,
|
|
||||||
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 시 NULL)",
|
|
||||||
)
|
|
||||||
|
|
||||||
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
||||||
JSON,
|
JSON,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from app.utils.prompts.schemas import MarketingPromptOutput
|
from app.utils.prompts.schemas import MarketingPromptOutput
|
||||||
|
|
||||||
class CrawlingRequest(BaseModel):
|
class CrawlingRequest(BaseModel):
|
||||||
@ -261,7 +261,6 @@ class ManualMarketingRequest(BaseModel):
|
|||||||
"store_name": "스테이 머뭄",
|
"store_name": "스테이 머뭄",
|
||||||
"address": "전북특별자치도 군산시 절골길 18",
|
"address": "전북특별자치도 군산시 절골길 18",
|
||||||
"category": "펜션",
|
"category": "펜션",
|
||||||
"official_site_url": "https://www.staymeomoom.com",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -269,23 +268,6 @@ class ManualMarketingRequest(BaseModel):
|
|||||||
store_name: str = Field(..., description="업체명 / 브랜드명")
|
store_name: str = Field(..., description="업체명 / 브랜드명")
|
||||||
address: str = Field(..., description="도로명 또는 지번 주소")
|
address: str = Field(..., description="도로명 또는 지번 주소")
|
||||||
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류")
|
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류")
|
||||||
official_site_url: Optional[str] = Field(
|
|
||||||
default=None,
|
|
||||||
max_length=2048,
|
|
||||||
description="업체 공식 홈페이지 링크 (선택, http/https만 허용). 영상 응답의 official_site_url로 노출됨",
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("official_site_url")
|
|
||||||
@classmethod
|
|
||||||
def _validate_official_site_url(cls, v: Optional[str]) -> Optional[str]:
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
return None
|
|
||||||
if not v.startswith(("http://", "https://")):
|
|
||||||
raise ValueError("official_site_url은 http:// 또는 https://로 시작해야 합니다.")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
|
|||||||
@ -1,380 +0,0 @@
|
|||||||
"""이미지 업로드 입력 검증과 continuation 소유권 검사 유틸리티."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from enum import StrEnum
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Literal
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import HTTPException, UploadFile, status
|
|
||||||
from sqlalchemy import select, text
|
|
||||||
|
|
||||||
from app.database.session import AsyncSessionLocal, engine
|
|
||||||
from app.home.models import Image
|
|
||||||
from app.home.schemas.home_schema import ImageUploadResultItem
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from config import azure_blob_settings
|
|
||||||
|
|
||||||
logger = get_logger("image_upload")
|
|
||||||
_image_upload_lock_slots = asyncio.Semaphore(
|
|
||||||
azure_blob_settings.IMAGE_UPLOAD_MAX_CONCURRENT_LOCKS
|
|
||||||
)
|
|
||||||
|
|
||||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
|
|
||||||
_HEIF_BRANDS = {
|
|
||||||
b"heic",
|
|
||||||
b"heix",
|
|
||||||
b"hevc",
|
|
||||||
b"hevx",
|
|
||||||
b"heim",
|
|
||||||
b"heis",
|
|
||||||
b"mif1",
|
|
||||||
b"msf1",
|
|
||||||
}
|
|
||||||
_IMAGE_SIGNATURE_BYTES = 64
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadLockTimeoutError(TimeoutError):
|
|
||||||
"""동일 task 이미지 변경 락을 제한 시간 내 획득하지 못했습니다."""
|
|
||||||
|
|
||||||
|
|
||||||
class BlobReferenceState(StrEnum):
|
|
||||||
"""불명확한 DB commit 뒤 Blob URL 참조 확인 결과."""
|
|
||||||
|
|
||||||
ALL = "all"
|
|
||||||
NONE = "none"
|
|
||||||
MIXED = "mixed"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def should_cleanup_failed_upload_blobs(
|
|
||||||
*,
|
|
||||||
commit_started: bool,
|
|
||||||
reference_state: BlobReferenceState = BlobReferenceState.UNKNOWN,
|
|
||||||
) -> bool:
|
|
||||||
"""DB commit 시도 후에는 참조가 없다고 확정된 경우에만 Blob을 삭제합니다."""
|
|
||||||
return not commit_started or reference_state == BlobReferenceState.NONE
|
|
||||||
|
|
||||||
|
|
||||||
def classify_blob_references(
|
|
||||||
expected_urls: set[str], found_urls: set[str]
|
|
||||||
) -> BlobReferenceState:
|
|
||||||
"""예상 Blob URL과 독립 조회 결과를 보존 우선 상태로 분류합니다."""
|
|
||||||
if not expected_urls:
|
|
||||||
return BlobReferenceState.NONE
|
|
||||||
matched_urls = expected_urls & found_urls
|
|
||||||
if matched_urls == expected_urls:
|
|
||||||
return BlobReferenceState.ALL
|
|
||||||
if not matched_urls:
|
|
||||||
return BlobReferenceState.NONE
|
|
||||||
return BlobReferenceState.MIXED
|
|
||||||
|
|
||||||
|
|
||||||
async def inspect_blob_references(
|
|
||||||
task_id: str,
|
|
||||||
blob_urls: set[str],
|
|
||||||
) -> BlobReferenceState:
|
|
||||||
"""독립 세션에서 이번 요청 Blob URL의 DB 반영 여부를 확인합니다.
|
|
||||||
|
|
||||||
commit 응답 유실 직후의 짧은 가시성 경합을 피하려고 NONE 결과만 세 번
|
|
||||||
재확인합니다. 존재/혼재/조회 실패는 즉시 보존 쪽으로 판정합니다.
|
|
||||||
"""
|
|
||||||
if not blob_urls:
|
|
||||||
return BlobReferenceState.NONE
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Image.img_url).where(
|
|
||||||
Image.task_id == task_id,
|
|
||||||
Image.img_url.in_(blob_urls),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
found_urls = set(result.scalars().all())
|
|
||||||
state = classify_blob_references(blob_urls, found_urls)
|
|
||||||
if state != BlobReferenceState.NONE:
|
|
||||||
return state
|
|
||||||
if attempt < 2:
|
|
||||||
await asyncio.sleep(0.1 * (attempt + 1))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(
|
|
||||||
f"[inspect_blob_references] DB verification failed - task_id: "
|
|
||||||
f"{task_id}, {type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
return BlobReferenceState.UNKNOWN
|
|
||||||
return BlobReferenceState.NONE
|
|
||||||
|
|
||||||
|
|
||||||
async def compensate_failed_upload_blobs(
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
blob_urls: set[str],
|
|
||||||
commit_started: bool,
|
|
||||||
cleanup: Callable[[], Awaitable[None]],
|
|
||||||
) -> BlobReferenceState:
|
|
||||||
"""DB 쓰기 실패 후 안전하다고 확인된 Blob만 보상 삭제합니다."""
|
|
||||||
reference_state = BlobReferenceState.UNKNOWN
|
|
||||||
if commit_started:
|
|
||||||
reference_state = await inspect_blob_references(task_id, blob_urls)
|
|
||||||
|
|
||||||
if should_cleanup_failed_upload_blobs(
|
|
||||||
commit_started=commit_started,
|
|
||||||
reference_state=reference_state,
|
|
||||||
):
|
|
||||||
await cleanup()
|
|
||||||
return reference_state
|
|
||||||
|
|
||||||
|
|
||||||
def validate_task_image_count(*, existing_count: int, incoming_count: int) -> int:
|
|
||||||
"""한 task에 누적 가능한 이미지 수를 검증하고 예상 총 개수를 반환합니다."""
|
|
||||||
total_count = existing_count + incoming_count
|
|
||||||
max_task_images = azure_blob_settings.IMAGE_UPLOAD_MAX_TASK_IMAGES
|
|
||||||
if total_count > max_task_images:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"한 작업에는 이미지를 최대 {max_task_images}개까지 추가할 수 있습니다.",
|
|
||||||
)
|
|
||||||
return total_count
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _image_upload_lock_slot(
|
|
||||||
lock_name: str, timeout_seconds: int
|
|
||||||
) -> AsyncIterator[None]:
|
|
||||||
"""DB 락 연결 슬롯 대기에도 동일한 제한 시간을 적용합니다."""
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
_image_upload_lock_slots.acquire(),
|
|
||||||
timeout=timeout_seconds,
|
|
||||||
)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise ImageUploadLockTimeoutError(lock_name) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
_image_upload_lock_slots.release()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def image_upload_task_lock(task_id: str) -> AsyncIterator[None]:
|
|
||||||
"""동일 task 요청 전체를 MySQL advisory lock으로 직렬화합니다.
|
|
||||||
|
|
||||||
yield dependency가 Azure 업로드부터 최종 태깅까지 connection을 점유하므로,
|
|
||||||
worker별 semaphore로 main DB pool의 나머지 connection을 보존합니다.
|
|
||||||
"""
|
|
||||||
lock_name = f"image_upload:{task_id}"
|
|
||||||
timeout_seconds = azure_blob_settings.IMAGE_UPLOAD_LOCK_TIMEOUT_SECONDS
|
|
||||||
lock_started = time.perf_counter()
|
|
||||||
|
|
||||||
async with _image_upload_lock_slot(lock_name, timeout_seconds):
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
lock_result = await connection.execute(
|
|
||||||
text("SELECT GET_LOCK(:lock_name, :timeout_seconds)"),
|
|
||||||
{
|
|
||||||
"lock_name": lock_name,
|
|
||||||
"timeout_seconds": timeout_seconds,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if lock_result.scalar_one_or_none() != 1:
|
|
||||||
raise ImageUploadLockTimeoutError(lock_name)
|
|
||||||
logger.info(
|
|
||||||
f"[image_upload_task_lock] ACQUIRED - task_id: {task_id}, "
|
|
||||||
f"wait_ms: {(time.perf_counter() - lock_started) * 1000:.1f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
release_task = asyncio.create_task(
|
|
||||||
connection.execute(
|
|
||||||
text("SELECT RELEASE_LOCK(:lock_name)"),
|
|
||||||
{"lock_name": lock_name},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
release_result = await asyncio.shield(release_task)
|
|
||||||
if release_result.scalar_one_or_none() != 1:
|
|
||||||
raise RuntimeError(f"RELEASE_LOCK failed: {lock_name}")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# shield 바깥 task가 다시 취소돼도 release query는 끝까지 기다립니다.
|
|
||||||
try:
|
|
||||||
await release_task
|
|
||||||
except BaseException as release_exc:
|
|
||||||
await connection.invalidate(release_exc)
|
|
||||||
raise
|
|
||||||
except BaseException as exc:
|
|
||||||
# 락이 남은 connection이 pool로 복귀하지 않도록 폐기합니다.
|
|
||||||
await connection.invalidate(exc)
|
|
||||||
logger.error(
|
|
||||||
f"[image_upload_task_lock] RELEASE_LOCK failed - "
|
|
||||||
f"{type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"[image_upload_task_lock] RELEASED - task_id: {task_id}, "
|
|
||||||
f"held_ms: {(time.perf_counter() - lock_started) * 1000:.1f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_image_extension(filename: str | None) -> bool:
|
|
||||||
"""파일명의 확장자가 지원 이미지 확장자인지 확인합니다."""
|
|
||||||
if not filename:
|
|
||||||
return False
|
|
||||||
return Path(filename).suffix.lower() in ALLOWED_IMAGE_EXTENSIONS
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_image_format(header: bytes) -> str | None:
|
|
||||||
"""신뢰할 수 없는 파일명/MIME 대신 파일 시그니처로 형식을 판별합니다."""
|
|
||||||
if header.startswith(b"\xff\xd8\xff"):
|
|
||||||
return "jpeg"
|
|
||||||
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
||||||
return "png"
|
|
||||||
if len(header) >= 12 and header.startswith(b"RIFF") and header[8:12] == b"WEBP":
|
|
||||||
return "webp"
|
|
||||||
if len(header) >= 12 and header[4:8] == b"ftyp":
|
|
||||||
brands = {header[8:12]}
|
|
||||||
brands.update(
|
|
||||||
header[index : index + 4] for index in range(16, len(header) - 3, 4)
|
|
||||||
)
|
|
||||||
if brands & _HEIF_BRANDS:
|
|
||||||
return "heif"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extension_matches_format(extension: str, detected_format: str) -> bool:
|
|
||||||
"""동일 포맷의 별칭을 고려해 확장자와 시그니처 일치 여부를 확인합니다."""
|
|
||||||
expected_formats = {
|
|
||||||
".jpg": "jpeg",
|
|
||||||
".jpeg": "jpeg",
|
|
||||||
".png": "png",
|
|
||||||
".webp": "webp",
|
|
||||||
".heic": "heif",
|
|
||||||
".heif": "heif",
|
|
||||||
}
|
|
||||||
return expected_formats.get(extension) == detected_format
|
|
||||||
|
|
||||||
|
|
||||||
async def inspect_upload_file(file: UploadFile) -> tuple[str, str, int]:
|
|
||||||
"""UploadFile을 상수 메모리로 검사하고 실제 바이트 크기를 반환합니다."""
|
|
||||||
original_name = file.filename or ""
|
|
||||||
if len(original_name) > 255:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="파일명은 255자를 초과할 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
extension = Path(original_name).suffix.lower()
|
|
||||||
max_file_size = azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES
|
|
||||||
validation_chunk_size = min(
|
|
||||||
azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES,
|
|
||||||
1024 * 1024,
|
|
||||||
)
|
|
||||||
total_size = 0
|
|
||||||
header = bytearray()
|
|
||||||
|
|
||||||
await file.seek(0)
|
|
||||||
try:
|
|
||||||
while chunk := await file.read(validation_chunk_size):
|
|
||||||
total_size += len(chunk)
|
|
||||||
if total_size > max_file_size:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
||||||
detail=(
|
|
||||||
f"파일 '{original_name}'이 최대 크기 "
|
|
||||||
f"{max_file_size // (1024 * 1024)} MiB를 초과합니다."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if len(header) < _IMAGE_SIGNATURE_BYTES:
|
|
||||||
remaining = _IMAGE_SIGNATURE_BYTES - len(header)
|
|
||||||
header.extend(chunk[:remaining])
|
|
||||||
finally:
|
|
||||||
await file.seek(0)
|
|
||||||
|
|
||||||
if total_size == 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"빈 파일은 업로드할 수 없습니다: {original_name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
detected_format = _detect_image_format(bytes(header))
|
|
||||||
if detected_format is None or not _extension_matches_format(
|
|
||||||
extension, detected_format
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=(
|
|
||||||
f"파일 내용과 확장자가 일치하는 지원 이미지가 아닙니다: {original_name}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return original_name, extension, total_size
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_continuation_task_id(task_id: str) -> str:
|
|
||||||
"""continuation task_id를 canonical UUID7 문자열로 검증합니다."""
|
|
||||||
try:
|
|
||||||
parsed = UUID(task_id)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
parsed = None
|
|
||||||
|
|
||||||
if (
|
|
||||||
parsed is None
|
|
||||||
or len(task_id) != 36
|
|
||||||
or parsed.version != 7
|
|
||||||
or str(parsed) != task_id.lower()
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="task_id는 올바른 UUID7 형식이어야 합니다.",
|
|
||||||
)
|
|
||||||
return str(parsed)
|
|
||||||
|
|
||||||
|
|
||||||
def _blob_url_prefix(user_uuid: str, task_id: str) -> str:
|
|
||||||
"""현재 사용자와 task에 허용된 Azure 이미지 URL prefix를 반환합니다."""
|
|
||||||
base_url = azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip("/")
|
|
||||||
return f"{base_url}/{user_uuid}/{task_id}/image/"
|
|
||||||
|
|
||||||
|
|
||||||
def assert_continuation_owner(
|
|
||||||
images: list[Image], user_uuid: str, task_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Image에 owner 컬럼이 없어 Blob 경로로 continuation 소유권을 검증합니다."""
|
|
||||||
if not images:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="이어 올릴 이미지 작업을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
base_prefix = f"{azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip('/')}/"
|
|
||||||
owner_prefix = _blob_url_prefix(user_uuid, task_id)
|
|
||||||
internal_urls = [
|
|
||||||
image.img_url for image in images if image.img_url.startswith(base_prefix)
|
|
||||||
]
|
|
||||||
|
|
||||||
if not internal_urls or any(
|
|
||||||
not image_url.startswith(owner_prefix) for image_url in internal_urls
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="이 이미지 업로드 작업을 이어서 수정할 권한이 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def image_result_item(image: Image) -> ImageUploadResultItem:
|
|
||||||
"""DB Image를 기존 응답 아이템으로 변환합니다."""
|
|
||||||
base_prefix = f"{azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip('/')}/"
|
|
||||||
source: Literal["url", "blob"] = (
|
|
||||||
"blob" if image.img_url.startswith(base_prefix) else "url"
|
|
||||||
)
|
|
||||||
return ImageUploadResultItem(
|
|
||||||
id=image.id,
|
|
||||||
img_name=image.img_name,
|
|
||||||
img_url=image.img_url,
|
|
||||||
img_order=image.img_order,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
@ -33,5 +33,5 @@ async def youtube_seo_description(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
return await seo_service.get_youtube_seo_description(
|
return await seo_service.get_youtube_seo_description(
|
||||||
request_body.video_id, current_user, session
|
request_body.task_id, current_user, session
|
||||||
)
|
)
|
||||||
|
|||||||
@ -95,6 +95,8 @@ YOUTUBE_SCOPES = [
|
|||||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||||
]
|
]
|
||||||
|
|
||||||
|
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Instagram/Facebook OAuth Scopes (추후 구현)
|
# Instagram/Facebook OAuth Scopes (추후 구현)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -8,12 +8,12 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
class YoutubeDescriptionRequest(BaseModel):
|
class YoutubeDescriptionRequest(BaseModel):
|
||||||
"""유튜브 SEO Description 제안 요청"""
|
"""유튜브 SEO Description 제안 요청"""
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"video_id": 123
|
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,132 +1,88 @@
|
|||||||
"""
|
"""
|
||||||
유튜브 SEO 서비스
|
유튜브 SEO 서비스
|
||||||
|
|
||||||
영상 제목/설명/해시태그를 생성하고 video 테이블에 저장합니다.
|
SEO description 생성 및 Redis 캐싱 로직을 처리합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException
|
||||||
|
from redis.asyncio import Redis
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import db_settings
|
||||||
from app.home.models import MarketingIntel, Project
|
from app.home.models import MarketingIntel, Project
|
||||||
|
from app.social.constants import YOUTUBE_SEO_HASH
|
||||||
from app.social.schemas import YoutubeDescriptionResponse
|
from app.social.schemas import YoutubeDescriptionResponse
|
||||||
from app.social.services.sns_metadata import apply_sns_metadata, has_stored_sns_metadata
|
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.video.models import Video
|
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
|
from app.utils.prompts.prompts import yt_upload_prompt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
redis_seo_client = Redis(
|
||||||
|
host=db_settings.REDIS_HOST,
|
||||||
|
port=db_settings.REDIS_PORT,
|
||||||
|
db=0,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SeoService:
|
class SeoService:
|
||||||
"""유튜브 SEO 비즈니스 로직 서비스"""
|
"""유튜브 SEO 비즈니스 로직 서비스"""
|
||||||
|
|
||||||
async def get_youtube_seo_description(
|
async def get_youtube_seo_description(
|
||||||
self,
|
self,
|
||||||
video_id: int,
|
task_id: str,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
"""
|
"""
|
||||||
저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다.
|
유튜브 SEO description 생성
|
||||||
|
|
||||||
|
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
|
||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[SEO_SERVICE] Load metadata - user: {current_user.user_uuid} / video_id: {video_id}"
|
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / task_id: {task_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
video = await self._get_owned_video(video_id, current_user.user_uuid, session)
|
cached = await self._get_from_redis(task_id)
|
||||||
if video is None:
|
if cached:
|
||||||
raise HTTPException(
|
return cached
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if has_stored_sns_metadata(video):
|
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
|
||||||
return self._response_from_video(video)
|
result = await self._generate_seo_description(task_id, current_user, session)
|
||||||
|
await self._set_to_redis(task_id, result)
|
||||||
|
|
||||||
result = await self.generate_and_save_for_video(video.id, session)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def generate_and_save_for_video(
|
|
||||||
self,
|
|
||||||
video_id: int,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> YoutubeDescriptionResponse | None:
|
|
||||||
"""GPT로 SEO를 생성해 지정한 video 행에 저장합니다. 워커/온디맨드 공용."""
|
|
||||||
video_result = await session.execute(select(Video).where(Video.id == video_id))
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
if video is None:
|
|
||||||
logger.warning(f"[SEO_SERVICE] Video NOT FOUND - video_id: {video_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if has_stored_sns_metadata(video):
|
|
||||||
return self._response_from_video(video)
|
|
||||||
|
|
||||||
result = await self._generate_seo_description(video.task_id, session)
|
|
||||||
apply_sns_metadata(video, result.title, result.description, result.keywords)
|
|
||||||
await session.flush()
|
|
||||||
logger.info(f"[SEO_SERVICE] Saved metadata - video_id: {video_id}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _get_owned_video(
|
|
||||||
self,
|
|
||||||
video_id: int,
|
|
||||||
user_uuid: str,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> Video | None:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Video)
|
|
||||||
.join(Project, Project.id == Video.project_id)
|
|
||||||
.where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Project.user_uuid == user_uuid,
|
|
||||||
Video.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def _generate_seo_description(
|
async def _generate_seo_description(
|
||||||
self,
|
self,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
current_user: User,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
"""GPT를 사용하여 SEO description 생성"""
|
"""GPT를 사용하여 SEO description 생성"""
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}")
|
||||||
from app.utils.prompts.prompts import yt_upload_prompt
|
|
||||||
|
|
||||||
logger.info(f"[SEO_SERVICE] Generating SEO - task_id: {task_id}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
project_result = await session.execute(
|
project_result = await session.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.task_id == task_id)
|
.where(
|
||||||
|
Project.task_id == task_id,
|
||||||
|
Project.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
.order_by(Project.created_at.desc())
|
.order_by(Project.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
project = project_result.scalar_one_or_none()
|
||||||
if project is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
marketing_result = await session.execute(
|
marketing_result = await session.execute(
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
)
|
)
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
if marketing_intelligence is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="마케팅 인텔리전스를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
||||||
|
|
||||||
@ -138,13 +94,12 @@ class SeoService:
|
|||||||
),
|
),
|
||||||
"language": project.language,
|
"language": project.language,
|
||||||
"target_keywords": hashtags,
|
"target_keywords": hashtags,
|
||||||
"industry": project.industry or "",
|
"industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
|
||||||
chatgpt = ChatgptService(timeout=180)
|
chatgpt = ChatgptService(timeout=180)
|
||||||
yt_seo_output = await chatgpt.generate_structured_output(
|
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
||||||
yt_upload_prompt, yt_seo_input_data
|
|
||||||
)
|
|
||||||
|
|
||||||
return YoutubeDescriptionResponse(
|
return YoutubeDescriptionResponse(
|
||||||
title=yt_seo_output.title,
|
title=yt_seo_output.title,
|
||||||
@ -152,8 +107,6 @@ class SeoService:
|
|||||||
keywords=hashtags,
|
keywords=hashtags,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
|
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -161,12 +114,18 @@ class SeoService:
|
|||||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
|
async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None:
|
||||||
return YoutubeDescriptionResponse(
|
field = f"task_id:{task_id}"
|
||||||
title=video.title or "",
|
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
||||||
description=video.description or "",
|
if yt_seo_info:
|
||||||
keywords=list(video.hashtags or []),
|
return YoutubeDescriptionResponse(**json.loads(yt_seo_info))
|
||||||
)
|
return None
|
||||||
|
|
||||||
|
async def _set_to_redis(self, task_id: str, yt_seo: YoutubeDescriptionResponse) -> None:
|
||||||
|
field = f"task_id:{task_id}"
|
||||||
|
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
|
||||||
|
await redis_seo_client.hset(YOUTUBE_SEO_HASH, field, yt_seo_info)
|
||||||
|
await redis_seo_client.expire(YOUTUBE_SEO_HASH, 3600)
|
||||||
|
|
||||||
|
|
||||||
seo_service = SeoService()
|
seo_service = SeoService()
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
"""SNS 업로드용 영상 메타데이터 비교/반영 헬퍼."""
|
|
||||||
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
|
||||||
def has_stored_sns_metadata(video: Video) -> bool:
|
|
||||||
"""video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
|
|
||||||
return bool(video.title)
|
|
||||||
|
|
||||||
|
|
||||||
def sns_metadata_changed(
|
|
||||||
video: Video,
|
|
||||||
title: str,
|
|
||||||
description: str | None,
|
|
||||||
tags: list[str] | None,
|
|
||||||
) -> bool:
|
|
||||||
"""게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다."""
|
|
||||||
stored_tags = list(video.hashtags or [])
|
|
||||||
incoming_tags = list(tags or [])
|
|
||||||
return (
|
|
||||||
(video.title or "") != title
|
|
||||||
or (video.description or "") != (description or "")
|
|
||||||
or stored_tags != incoming_tags
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_sns_metadata(
|
|
||||||
video: Video,
|
|
||||||
title: str,
|
|
||||||
description: str | None,
|
|
||||||
hashtags: list[str] | None,
|
|
||||||
) -> None:
|
|
||||||
"""video 행에 SNS 메타데이터를 반영합니다."""
|
|
||||||
video.title = title
|
|
||||||
video.description = description
|
|
||||||
video.hashtags = list(hashtags or [])
|
|
||||||
@ -26,7 +26,6 @@ from app.social.schemas import (
|
|||||||
SocialUploadRequest,
|
SocialUploadRequest,
|
||||||
)
|
)
|
||||||
from app.social.services.account_service import SocialAccountService
|
from app.social.services.account_service import SocialAccountService
|
||||||
from app.social.services.sns_metadata import apply_sns_metadata, sns_metadata_changed
|
|
||||||
from app.social.worker.upload_task import process_social_upload
|
from app.social.worker.upload_task import process_social_upload
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
@ -77,12 +76,6 @@ class SocialUploadService:
|
|||||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if sns_metadata_changed(video, body.title, body.description, body.tags):
|
|
||||||
apply_sns_metadata(video, body.title, body.description, body.tags)
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_SERVICE] video SNS 메타데이터 갱신 - video_id: {body.video_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 소셜 계정 조회 및 소유권 검증
|
# 2. 소셜 계정 조회 및 소유권 검증
|
||||||
account = await self._account_service.get_account_by_id(
|
account = await self._account_service.get_account_by_id(
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
|
|||||||
@ -6,10 +6,6 @@ from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommen
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
# medium 추론은 출력 비용의 80%를 차지하고, minimal은 A/B 비교(4회)에서 narrative 점수가
|
|
||||||
# welcome 단계로 편향되고 태그를 과다 선택하는 패턴이 반복돼 low로 고정한다.
|
|
||||||
IMAGE_TAG_REASONING_EFFORT = "low"
|
|
||||||
|
|
||||||
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list
|
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list
|
||||||
chatgpt = ChatgptService(model_type="gpt")
|
chatgpt = ChatgptService(model_type="gpt")
|
||||||
image_input_data = {
|
image_input_data = {
|
||||||
@ -21,7 +17,7 @@ async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_
|
|||||||
"motion_recommended" : list(MotionRecommended)
|
"motion_recommended" : list(MotionRecommended)
|
||||||
}
|
}
|
||||||
|
|
||||||
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT)
|
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
|
||||||
return image_result
|
return image_result
|
||||||
|
|
||||||
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
|
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
|
||||||
@ -35,7 +31,7 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
|
|||||||
"motion_recommended" : list(MotionRecommended)
|
"motion_recommended" : list(MotionRecommended)
|
||||||
}for image_url in image_url_list]
|
}for image_url in image_url_list]
|
||||||
|
|
||||||
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], True, silent = True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for image_input_data in image_input_data_list]
|
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
|
||||||
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
||||||
MAX_RETRY = 2
|
MAX_RETRY = 2
|
||||||
for _ in range(MAX_RETRY):
|
for _ in range(MAX_RETRY):
|
||||||
@ -44,7 +40,7 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
|
|||||||
if not failed_idx:
|
if not failed_idx:
|
||||||
break
|
break
|
||||||
retried = await asyncio.gather(
|
retried = await asyncio.gather(
|
||||||
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], True, silent=True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for i in failed_idx],
|
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
|
||||||
return_exceptions=True
|
return_exceptions=True
|
||||||
)
|
)
|
||||||
for i, result in zip(failed_idx, retried):
|
for i, result in zip(failed_idx, retried):
|
||||||
|
|||||||
@ -574,15 +574,8 @@ class CreatomateService:
|
|||||||
다운로드 실패를 배제로 오판해 풀 전체가 날아가는 것을 막기 위함.
|
다운로드 실패를 배제로 오판해 풀 전체가 날아가는 것을 막기 위함.
|
||||||
일반 씬 슬롯은 태그 점수를 그대로 반환한다.
|
일반 씬 슬롯은 태그 점수를 그대로 반환한다.
|
||||||
"""
|
"""
|
||||||
is_thumbnail = slot.endswith(THUMBNAIL_SLOT_MARKER)
|
scores = self.calculate_image_slot_score_multi(pool_subset, slot)
|
||||||
if is_thumbnail and self.parse_slot_name_to_tag(slot) is None:
|
if not thumbnail_fitness_map or not slot.endswith(THUMBNAIL_SLOT_MARKER):
|
||||||
# 슬롯명이 명명 규칙을 어겨 태그 매칭이 불가능한 썸네일 슬롯.
|
|
||||||
# 태그 점수를 0으로 두면 pool 첫 컷이 뽑혀 사실상 무작위가 되므로
|
|
||||||
# 전 이미지 중립(1.0)으로 두고 아래 픽셀 적합도만으로 순위를 가른다.
|
|
||||||
scores = [1.0] * len(pool_subset)
|
|
||||||
else:
|
|
||||||
scores = self.calculate_image_slot_score_multi(pool_subset, slot)
|
|
||||||
if not thumbnail_fitness_map or not is_thumbnail:
|
|
||||||
return scores
|
return scores
|
||||||
|
|
||||||
adjusted = []
|
adjusted = []
|
||||||
@ -596,31 +589,6 @@ class CreatomateService:
|
|||||||
adjusted.append(score * fitness["score"])
|
adjusted.append(score * fitness["score"])
|
||||||
return adjusted
|
return adjusted
|
||||||
|
|
||||||
def _collect_thumbnail_slots(self, template_component_data: dict) -> list[str]:
|
|
||||||
"""배정 대상 썸네일 슬롯(-9999)을 수집합니다.
|
|
||||||
|
|
||||||
일반 씬 슬롯과 달리 슬롯명 파싱에 실패해도 제외하지 않는다. 썸네일은
|
|
||||||
노출 면적이 가장 큰 표면이라, 미배정 시 modify_element가 템플릿 원본
|
|
||||||
(샘플 이미지)을 그대로 남겨 완성 영상에 그대로 나가기 때문이다. 태그
|
|
||||||
매칭이 불가능한 슬롯은 _slot_scores_with_fitness가 픽셀 적합도만으로
|
|
||||||
고른다. 단 '-fixed' 고정 자산은 여기서도 제외한다.
|
|
||||||
|
|
||||||
파싱 실패는 템플릿 슬롯명 오타이므로 ERROR로 남겨 드러나게 한다
|
|
||||||
(조용히 넘어가면 샘플 이미지가 나가도 아무도 알아채지 못한다).
|
|
||||||
"""
|
|
||||||
slots = [
|
|
||||||
name for name, t in template_component_data.items()
|
|
||||||
if t == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
|
|
||||||
and not is_fixed_slot_name(name)
|
|
||||||
]
|
|
||||||
for name in slots:
|
|
||||||
if self.parse_slot_name_to_tag(name) is None:
|
|
||||||
logger.error(
|
|
||||||
f"[_collect_thumbnail_slots] 썸네일 슬롯명이 명명 규칙 위반 — "
|
|
||||||
f"'{name}' — 템플릿 슬롯명 수정 필요. 픽셀 적합도 기준으로 폴백 배정합니다."
|
|
||||||
)
|
|
||||||
return slots
|
|
||||||
|
|
||||||
def rank_thumbnail_candidates(
|
def rank_thumbnail_candidates(
|
||||||
self,
|
self,
|
||||||
template: dict,
|
template: dict,
|
||||||
@ -639,7 +607,11 @@ class CreatomateService:
|
|||||||
후보가 없는 슬롯은 키 자체를 포함하지 않는다.
|
후보가 없는 슬롯은 키 자체를 포함하지 않는다.
|
||||||
"""
|
"""
|
||||||
component = self.parse_template_component_name(template["source"]["elements"])
|
component = self.parse_template_component_name(template["source"]["elements"])
|
||||||
thumbnail_slots = self._collect_thumbnail_slots(component)
|
thumbnail_slots = [
|
||||||
|
name for name, t in component.items()
|
||||||
|
if t == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
|
||||||
|
and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
|
||||||
|
]
|
||||||
result: dict[str, list[dict]] = {}
|
result: dict[str, list[dict]] = {}
|
||||||
for slot in thumbnail_slots:
|
for slot in thumbnail_slots:
|
||||||
scores = self._slot_scores_with_fitness(taged_image_list, slot, thumbnail_fitness_map)
|
scores = self._slot_scores_with_fitness(taged_image_list, slot, thumbnail_fitness_map)
|
||||||
@ -697,13 +669,9 @@ class CreatomateService:
|
|||||||
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지
|
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지
|
||||||
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고
|
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고
|
||||||
# 무작위 이미지로 덮어써진다.
|
# 무작위 이미지로 덮어써진다.
|
||||||
# 썸네일 슬롯(-9999)은 파싱 실패해도 배정해야 하므로 여기서 제외하고
|
|
||||||
# _collect_thumbnail_slots가 별도로 수집한다.
|
|
||||||
image_slots = [
|
image_slots = [
|
||||||
name for name, t in template_component_data.items()
|
name for name, t in template_component_data.items()
|
||||||
if t == "image" and not is_fixed_slot_name(name)
|
if t == "image" and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
|
||||||
and not name.endswith(THUMBNAIL_SLOT_MARKER)
|
|
||||||
and self.parse_slot_name_to_tag(name) is not None
|
|
||||||
]
|
]
|
||||||
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
|
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
|
||||||
|
|
||||||
@ -718,7 +686,8 @@ class CreatomateService:
|
|||||||
# thumbnail_choice(비전 LLM 최종 선택)가 해당 슬롯에 있으면 그 이미지를,
|
# thumbnail_choice(비전 LLM 최종 선택)가 해당 슬롯에 있으면 그 이미지를,
|
||||||
# 없으면(선택 실패/미제공) 결정론적 최고점 컷을 사용한다 — 폴백 안전.
|
# 없으면(선택 실패/미제공) 결정론적 최고점 컷을 사용한다 — 폴백 안전.
|
||||||
thumbnail_choice = thumbnail_choice or {}
|
thumbnail_choice = thumbnail_choice or {}
|
||||||
thumbnail_slots = self._collect_thumbnail_slots(template_component_data)
|
thumbnail_slots = [s for s in image_slots if s.endswith(THUMBNAIL_SLOT_MARKER)]
|
||||||
|
image_slots = [s for s in image_slots if not s.endswith(THUMBNAIL_SLOT_MARKER)]
|
||||||
for slot in thumbnail_slots:
|
for slot in thumbnail_slots:
|
||||||
if not pool:
|
if not pool:
|
||||||
logger.warning(f"[template_matching_taged_image] 이미지 풀 없음 — 썸네일 슬롯 배정 불가: {slot}")
|
logger.warning(f"[template_matching_taged_image] 이미지 풀 없음 — 썸네일 슬롯 배정 불가: {slot}")
|
||||||
@ -916,13 +885,7 @@ class CreatomateService:
|
|||||||
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
|
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
|
||||||
|
|
||||||
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
|
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
|
||||||
|
파싱 실패 시 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
|
||||||
위치 기반 파싱에 실패하면 토큰 위치를 무시한 대조로 한 번 더 시도한다
|
|
||||||
(_parse_slot_name_loosely). 슬롯명 오타로 슬롯이 배정에서 빠지면
|
|
||||||
modify_element가 템플릿 원본(샘플 이미지)을 그대로 남겨 완성 영상에
|
|
||||||
그대로 나가기 때문이다.
|
|
||||||
|
|
||||||
둘 다 실패하면 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
tag_list = slot_name.split("-")
|
tag_list = slot_name.split("-")
|
||||||
@ -944,64 +907,9 @@ class CreatomateService:
|
|||||||
}
|
}
|
||||||
return tag_dict
|
return tag_dict
|
||||||
except (ValueError, IndexError) as e:
|
except (ValueError, IndexError) as e:
|
||||||
loose = self._parse_slot_name_loosely(tag_list)
|
|
||||||
if loose is not None:
|
|
||||||
logger.warning(
|
|
||||||
f"[parse_slot_name_to_tag] 슬롯명이 명명 규칙 위반: '{slot_name}' — {e} — "
|
|
||||||
f"위치 무시 대조로 복구: { {k: v.value for k, v in loose.items()} } — 템플릿 슬롯명 수정 권장"
|
|
||||||
)
|
|
||||||
return loose
|
|
||||||
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 파싱 실패: '{slot_name}' — {e} — 슬롯 skip")
|
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 파싱 실패: '{slot_name}' — {e} — 슬롯 skip")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _parse_slot_name_loosely(self, tag_list: list[str]) -> dict[str, StrEnum] | None:
|
|
||||||
"""토큰 위치를 무시하고 각 enum에 대조해 태그를 복구합니다.
|
|
||||||
|
|
||||||
위치 기반 파싱이 실패했을 때만 호출한다. 토큰 하나는 한 카테고리에만
|
|
||||||
쓰이며, 앞선 토큰부터 순서대로 소비한다(중복 후보가 있으면 앞선 것 채택
|
|
||||||
— 위치 기반과 같은 값을 고르게 된다).
|
|
||||||
|
|
||||||
space_type/subject/narrative는 필수다. 이 셋을 못 채우면 슬롯명이 아닌
|
|
||||||
것으로 보고 None을 반환한다(고정 자산·비규칙 요소가 배정 대상에 섞여
|
|
||||||
무작위 이미지로 덮어써지는 것을 막기 위함). camera/motion은 선택이며
|
|
||||||
못 찾으면 키 자체를 넣지 않는다 — 점수 계산은 태그 딕셔너리를 순회하므로
|
|
||||||
없는 키는 자연히 가중치에서 빠진다.
|
|
||||||
"""
|
|
||||||
used: set[int] = set()
|
|
||||||
|
|
||||||
def take(converter) -> StrEnum | None:
|
|
||||||
for idx, token in enumerate(tag_list):
|
|
||||||
if idx in used:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
value = converter(token)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if value is not None:
|
|
||||||
used.add(idx)
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
space_type = take(SpaceType)
|
|
||||||
subject = take(Subject)
|
|
||||||
narrative = take(NarrativePhase)
|
|
||||||
if space_type is None or subject is None or narrative is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
camera = take(Camera)
|
|
||||||
motion = take(lambda t: MOTION_TOKEN_NORMALIZATION.get(t) or MotionRecommended(t))
|
|
||||||
|
|
||||||
tag_dict: dict[str, StrEnum] = {
|
|
||||||
"space_type": space_type,
|
|
||||||
"subject": subject,
|
|
||||||
"narrative_preference": narrative,
|
|
||||||
}
|
|
||||||
if camera is not None:
|
|
||||||
tag_dict["camera"] = camera
|
|
||||||
if motion is not None:
|
|
||||||
tag_dict["motion_recommended"] = motion
|
|
||||||
return tag_dict
|
|
||||||
|
|
||||||
def elements_connect_resource_blackbox(
|
def elements_connect_resource_blackbox(
|
||||||
self,
|
self,
|
||||||
elements: list,
|
elements: list,
|
||||||
|
|||||||
@ -131,8 +131,7 @@ if (originalQuery) {
|
|||||||
cls,
|
cls,
|
||||||
place_id: str,
|
place_id: str,
|
||||||
payloads: list[dict],
|
payloads: list[dict],
|
||||||
capture_apollo: bool = False,
|
) -> list[dict | None] | None:
|
||||||
) -> list[dict | None] | None | tuple[list[dict | None] | None, str | None]:
|
|
||||||
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
|
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
|
||||||
|
|
||||||
네이버 pcmap GraphQL은 두 헤더를 검사한다:
|
네이버 pcmap GraphQL은 두 헤더를 검사한다:
|
||||||
@ -145,19 +144,12 @@ if (originalQuery) {
|
|||||||
Args:
|
Args:
|
||||||
place_id: 네이버 place ID
|
place_id: 네이버 place ID
|
||||||
payloads: GraphQL POST 본문 목록
|
payloads: GraphQL POST 본문 목록
|
||||||
capture_apollo: True면 place 페이지에 인라인된 __APOLLO_STATE__
|
|
||||||
JSON 문자열도 함께 캡처해 (results, apollo_json) 튜플로 반환.
|
|
||||||
(GraphQL base가 노출하지 않는 homepages 등 SSR 캐시 전용 필드용)
|
|
||||||
Returns:
|
Returns:
|
||||||
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None.
|
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None.
|
||||||
capture_apollo=True면 (위 결과, apollo_json 또는 None) 튜플.
|
|
||||||
"""
|
"""
|
||||||
def _ret(results, apollo=None):
|
|
||||||
return (results, apollo) if capture_apollo else results
|
|
||||||
|
|
||||||
if not cls.is_ready:
|
if not cls.is_ready:
|
||||||
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
|
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
|
||||||
return _ret(None)
|
return None
|
||||||
|
|
||||||
page = await cls._new_stealth_page()
|
page = await cls._new_stealth_page()
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
@ -193,17 +185,7 @@ if (originalQuery) {
|
|||||||
|
|
||||||
if not captured.get("tok"):
|
if not captured.get("tok"):
|
||||||
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패")
|
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패")
|
||||||
return _ret(None)
|
return None
|
||||||
|
|
||||||
apollo_json: str | None = None
|
|
||||||
if capture_apollo:
|
|
||||||
try:
|
|
||||||
apollo_json = await page.evaluate(
|
|
||||||
"() => { try { return JSON.stringify(window.__APOLLO_STATE__ || null); }"
|
|
||||||
" catch (e) { return null; } }"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[NvMapPwScraper] __APOLLO_STATE__ 캡처 실패: {e}")
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -238,11 +220,11 @@ if (originalQuery) {
|
|||||||
results.append(None)
|
results.append(None)
|
||||||
else:
|
else:
|
||||||
results.append(r)
|
results.append(r)
|
||||||
return _ret(results, apollo_json)
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
|
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
|
||||||
return _ret(None)
|
return None
|
||||||
finally:
|
finally:
|
||||||
await page.close()
|
await page.close()
|
||||||
|
|
||||||
|
|||||||
@ -112,7 +112,6 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
self.facility_info: str | None = None
|
self.facility_info: str | None = None
|
||||||
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
|
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
|
||||||
self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend)
|
self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend)
|
||||||
self.official_site_url: str | None = None # 업체 공식 링크 (base.homepages 대표 URL)
|
|
||||||
|
|
||||||
def _get_request_headers(self) -> dict:
|
def _get_request_headers(self) -> dict:
|
||||||
headers = self.DEFAULT_HEADERS.copy()
|
headers = self.DEFAULT_HEADERS.copy()
|
||||||
@ -257,11 +256,9 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
# self.scrap_type = "GraphQL-Browser"
|
# self.scrap_type = "GraphQL-Browser"
|
||||||
|
|
||||||
# ── 실제 브라우저로 WTM 캡차 우회 ──
|
# ── 실제 브라우저로 WTM 캡차 우회 ──
|
||||||
data, stats_data, extra_photo_urls, biz_photo_urls, homepage_url = await self._scrap_via_browser(place_id)
|
data, stats_data, extra_photo_urls, biz_photo_urls = await self._scrap_via_browser(place_id)
|
||||||
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
|
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
|
||||||
# 홈페이지 링크는 브라우저 캡처가 실패한 경우에만 HTML 경로로 보충한다.
|
fac_data = await self._get_facility_string(place_id)
|
||||||
fac_data, html_homepage = await self._get_facility_and_homepage(place_id)
|
|
||||||
homepage_url = homepage_url or html_homepage
|
|
||||||
self.scrap_type = "GraphQL-Browser"
|
self.scrap_type = "GraphQL-Browser"
|
||||||
|
|
||||||
self.rawdata = data
|
self.rawdata = data
|
||||||
@ -300,43 +297,14 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
self.facility_info = fac_data
|
self.facility_info = fac_data
|
||||||
self.voted_keyword_stats = stats_data
|
self.voted_keyword_stats = stats_data
|
||||||
self.menu_info = business.get("menus") or None
|
self.menu_info = business.get("menus") or None
|
||||||
self.official_site_url = homepage_url
|
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@staticmethod
|
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]:
|
||||||
def _extract_homepage_from_html(html: str) -> str | None:
|
|
||||||
"""플레이스 페이지 HTML의 __APOLLO_STATE__에서 홈페이지 링크를 추출한다.
|
|
||||||
|
|
||||||
GraphQL placeDetail(base)는 homepages 필드를 노출하지 않으므로(400),
|
|
||||||
SSR 페이지에 인라인된 Apollo 캐시의 "homepages" 객체를 직접 파싱한다.
|
|
||||||
대표(repr) 링크 우선, 죽은 링크(isDeadUrl)는 제외. 홈페이지 항목은
|
|
||||||
자체 홈페이지 외에 인스타그램/블로그 등일 수도 있다 — 업체가 대표로
|
|
||||||
등록한 링크를 그대로 신뢰한다.
|
|
||||||
"""
|
|
||||||
decoder = json.JSONDecoder()
|
|
||||||
search_from = 0
|
|
||||||
while True:
|
|
||||||
idx = html.find('"homepages":', search_from)
|
|
||||||
if idx == -1:
|
|
||||||
return None
|
|
||||||
search_from = idx + 1
|
|
||||||
try:
|
|
||||||
homepages, _ = decoder.raw_decode(html, idx + len('"homepages":'))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if not isinstance(homepages, dict):
|
|
||||||
continue
|
|
||||||
candidates = [homepages.get("repr"), *(homepages.get("etc") or [])]
|
|
||||||
for item in candidates:
|
|
||||||
if isinstance(item, dict) and item.get("url") and not item.get("isDeadUrl"):
|
|
||||||
return item["url"]
|
|
||||||
|
|
||||||
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict], str | None]:
|
|
||||||
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls, homepage_url)
|
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
GraphQLException: 브라우저 폴백마저 실패한 경우
|
GraphQLException: 브라우저 폴백마저 실패한 경우
|
||||||
@ -414,14 +382,10 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
|
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
|
||||||
MAX_RETRY = 3
|
MAX_RETRY = 3
|
||||||
results = None
|
results = None
|
||||||
apollo_json: str | None = None
|
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for attempt in range(1, MAX_RETRY + 1):
|
for attempt in range(1, MAX_RETRY + 1):
|
||||||
try:
|
try:
|
||||||
results, captured_apollo = await NvMapPwScraper.fetch_graphql(
|
results = await NvMapPwScraper.fetch_graphql(place_id, payloads)
|
||||||
place_id, payloads, capture_apollo=True
|
|
||||||
)
|
|
||||||
apollo_json = apollo_json or captured_apollo
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = e
|
last_error = e
|
||||||
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
|
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
|
||||||
@ -475,17 +439,8 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
|
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 홈페이지 링크: GraphQL base는 homepages를 노출하지 않으므로(400),
|
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}")
|
||||||
# 브라우저가 로드한 place 페이지의 __APOLLO_STATE__ JSON에서 추출한다.
|
return data, stats_data, extra_photo_urls, biz_urls
|
||||||
homepage_url = (
|
|
||||||
self._extract_homepage_from_html(apollo_json) if apollo_json else None
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}, "
|
|
||||||
f"homepage: {homepage_url or '없음'}"
|
|
||||||
)
|
|
||||||
return data, stats_data, extra_photo_urls, biz_urls, homepage_url
|
|
||||||
|
|
||||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||||
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||||
@ -566,39 +521,29 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
|
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _get_facility_and_homepage(self, place_id: str) -> tuple[str | None, str | None]:
|
async def _get_facility_string(self, place_id: str) -> str | None:
|
||||||
"""장소 페이지에서 편의시설 정보와 홈페이지 링크를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
|
"""장소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
place_id: 네이버 지도 장소 ID
|
place_id: 네이버 지도 장소 ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(편의시설 정보 문자열 또는 None, 홈페이지 링크 또는 None)
|
편의시설 정보 문자열 또는 None
|
||||||
"""
|
"""
|
||||||
facility: str | None = None
|
|
||||||
homepage: str | None = None
|
|
||||||
place_types = ["place", "accommodation", "restaurant"]
|
place_types = ["place", "accommodation", "restaurant"]
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
for place_type in place_types:
|
for place_type in place_types:
|
||||||
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
|
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
|
||||||
async with session.get(url, headers=self._get_request_headers()) as response:
|
async with session.get(url, headers=self._get_request_headers()) as response:
|
||||||
raw = await response.read()
|
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
||||||
if homepage is None:
|
|
||||||
homepage = self._extract_homepage_from_html(
|
|
||||||
raw.decode("utf-8", errors="ignore")
|
|
||||||
)
|
|
||||||
if facility is None:
|
|
||||||
soup = bs4.BeautifulSoup(raw, "html.parser")
|
|
||||||
c_elem = soup.find("span", "place_blind", string="편의")
|
c_elem = soup.find("span", "place_blind", string="편의")
|
||||||
if c_elem:
|
if c_elem:
|
||||||
facility = c_elem.parent.parent.find("div").string
|
return c_elem.parent.parent.find("div").string
|
||||||
if facility is not None and homepage is not None:
|
return None
|
||||||
break
|
|
||||||
return facility, homepage
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[NvMapScraper] Failed to get facility/homepage info: {e}")
|
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
|
||||||
return facility, homepage
|
return None
|
||||||
|
|
||||||
|
|
||||||
# if __name__ == "__main__":
|
# if __name__ == "__main__":
|
||||||
|
|||||||
@ -47,19 +47,6 @@ class ChatgptService:
|
|||||||
case _:
|
case _:
|
||||||
raise NotImplementedError(f"Unknown Provider : {model_type}")
|
raise NotImplementedError(f"Unknown Provider : {model_type}")
|
||||||
|
|
||||||
def _log_usage(self, response, model: str, output_format: type[BaseModel]) -> None:
|
|
||||||
usage = getattr(response, "usage", None)
|
|
||||||
if usage is None:
|
|
||||||
return
|
|
||||||
# 토큰 소모량 로깅 (필요 시 주석 해제)
|
|
||||||
# cached = getattr(getattr(usage, "prompt_tokens_details", None), "cached_tokens", None) or 0
|
|
||||||
# reasoning = getattr(getattr(usage, "completion_tokens_details", None), "reasoning_tokens", None) or 0
|
|
||||||
# logger.info(
|
|
||||||
# f"[ChatgptService({self.model_type})] usage model={model} output={output_format.__name__} "
|
|
||||||
# f"prompt={usage.prompt_tokens} cached={cached} "
|
|
||||||
# f"completion={usage.completion_tokens} reasoning={reasoning} total={usage.total_tokens}"
|
|
||||||
# )
|
|
||||||
|
|
||||||
async def _call_pydantic_output(
|
async def _call_pydantic_output(
|
||||||
self,
|
self,
|
||||||
prompt : str,
|
prompt : str,
|
||||||
@ -128,8 +115,7 @@ class ChatgptService:
|
|||||||
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||||
model : str,
|
model : str,
|
||||||
img_url : str,
|
img_url : str,
|
||||||
image_detail_high : bool,
|
image_detail_high : bool) -> BaseModel:
|
||||||
reasoning_effort : Optional[str] = None) -> BaseModel:
|
|
||||||
content = []
|
content = []
|
||||||
if img_url:
|
if img_url:
|
||||||
content.append({
|
content.append({
|
||||||
@ -143,16 +129,13 @@ class ChatgptService:
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"text": prompt
|
"text": prompt
|
||||||
})
|
})
|
||||||
# gpt-5.4 계열/Gemini 호환 엔드포인트는 허용 값이 다르거나 파라미터를 거부하므로 지정된 경우에만 전달
|
|
||||||
extra_kwargs = {"reasoning_effort": reasoning_effort} if reasoning_effort else {}
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(self.max_retries + 1):
|
for attempt in range(self.max_retries + 1):
|
||||||
try:
|
try:
|
||||||
response = await self.client.beta.chat.completions.parse(
|
response = await self.client.beta.chat.completions.parse(
|
||||||
model=model,
|
model=model,
|
||||||
messages=[{"role": "user", "content": content}],
|
messages=[{"role": "user", "content": content}],
|
||||||
response_format=output_format,
|
response_format=output_format
|
||||||
**extra_kwargs,
|
|
||||||
)
|
)
|
||||||
except (ValidationError, json.JSONDecodeError) as e:
|
except (ValidationError, json.JSONDecodeError) as e:
|
||||||
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
|
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
|
||||||
@ -165,7 +148,6 @@ class ChatgptService:
|
|||||||
if attempt < self.max_retries:
|
if attempt < self.max_retries:
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
||||||
continue
|
continue
|
||||||
self._log_usage(response, model, output_format)
|
|
||||||
# Response 디버그 로깅
|
# Response 디버그 로깅
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
# logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
# logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
||||||
@ -243,7 +225,6 @@ class ChatgptService:
|
|||||||
continue
|
continue
|
||||||
raise last_error
|
raise last_error
|
||||||
|
|
||||||
self._log_usage(response, model, output_format)
|
|
||||||
choice = response.choices[0]
|
choice = response.choices[0]
|
||||||
if choice.finish_reason == "stop":
|
if choice.finish_reason == "stop":
|
||||||
return choice.message.parsed
|
return choice.message.parsed
|
||||||
@ -261,8 +242,7 @@ class ChatgptService:
|
|||||||
input_data : dict,
|
input_data : dict,
|
||||||
img_url : Optional[str] = None,
|
img_url : Optional[str] = None,
|
||||||
img_detail_high : bool = False,
|
img_detail_high : bool = False,
|
||||||
silent : bool = True,
|
silent : bool = True
|
||||||
reasoning_effort : Optional[str] = None,
|
|
||||||
) -> BaseModel:
|
) -> BaseModel:
|
||||||
prompt_text = prompt.build_prompt(input_data, silent)
|
prompt_text = prompt.build_prompt(input_data, silent)
|
||||||
|
|
||||||
@ -273,5 +253,5 @@ class ChatgptService:
|
|||||||
# GPT API 호출
|
# GPT API 호출
|
||||||
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||||
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
||||||
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high, reasoning_effort)
|
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
||||||
return parsed
|
return parsed
|
||||||
@ -32,17 +32,12 @@ URL 경로 형식:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import UploadFile
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import azure_blob_settings
|
from config import azure_blob_settings
|
||||||
@ -50,15 +45,6 @@ from config import azure_blob_settings
|
|||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("blob")
|
logger = get_logger("blob")
|
||||||
|
|
||||||
|
|
||||||
class BlobUploadTooLargeError(ValueError):
|
|
||||||
"""스트리밍 중 파일 크기 상한을 초과했을 때 발생합니다."""
|
|
||||||
|
|
||||||
def __init__(self, max_size_bytes: int):
|
|
||||||
self.max_size_bytes = max_size_bytes
|
|
||||||
super().__init__(f"업로드 파일은 {max_size_bytes} bytes를 초과할 수 없습니다.")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -90,23 +76,6 @@ async def close_shared_blob_client() -> None:
|
|||||||
logger.info("[AzureBlobUploader] Shared HTTP client closed")
|
logger.info("[AzureBlobUploader] Shared HTTP client closed")
|
||||||
|
|
||||||
|
|
||||||
def to_playback_url(blob_url: str | None) -> str | None:
|
|
||||||
"""DB에 저장된 SAS 미포함 공개 URL에 읽기용 SAS 토큰을 붙여 반환합니다.
|
|
||||||
|
|
||||||
Azure Blob 익명(공개) 접근은 x-ms-version 헤더가 없어 Range 요청을 지원하지
|
|
||||||
않는 구버전 API로 처리되어 브라우저에서 영상/오디오 seek이 동작하지 않는다.
|
|
||||||
SAS 토큰(sv= 포함)을 붙이면 최신 API 버전으로 처리되어 Range/Accept-Ranges가
|
|
||||||
정상 동작한다. DB에는 SAS 없는 URL을 그대로 저장하고, 응답 시점에만 붙인다.
|
|
||||||
"""
|
|
||||||
if not blob_url:
|
|
||||||
return blob_url
|
|
||||||
sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN.strip("?'\"")
|
|
||||||
if not sas_token:
|
|
||||||
return blob_url
|
|
||||||
separator = "&" if "?" in blob_url else "?"
|
|
||||||
return f"{blob_url}{separator}{sas_token}"
|
|
||||||
|
|
||||||
|
|
||||||
class AzureBlobUploader:
|
class AzureBlobUploader:
|
||||||
"""Azure Blob Storage 업로드 클래스
|
"""Azure Blob Storage 업로드 클래스
|
||||||
|
|
||||||
@ -131,8 +100,6 @@ class AzureBlobUploader:
|
|||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
".bmp": "image/bmp",
|
".bmp": "image/bmp",
|
||||||
".heic": "image/heic",
|
|
||||||
".heif": "image/heif",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, user_uuid: str, task_id: str):
|
def __init__(self, user_uuid: str, task_id: str):
|
||||||
@ -144,7 +111,7 @@ class AzureBlobUploader:
|
|||||||
"""
|
"""
|
||||||
self._user_uuid = user_uuid
|
self._user_uuid = user_uuid
|
||||||
self._task_id = task_id
|
self._task_id = task_id
|
||||||
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip("/")
|
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
|
||||||
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
||||||
self._last_public_url: str = ""
|
self._last_public_url: str = ""
|
||||||
|
|
||||||
@ -237,12 +204,8 @@ class AzureBlobUploader:
|
|||||||
logger.debug(f"[{log_prefix}] Starting upload... "
|
logger.debug(f"[{log_prefix}] Starting upload... "
|
||||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
f"(size: {size} bytes, timeout: {timeout}s)")
|
||||||
|
|
||||||
request_headers = {
|
|
||||||
**headers,
|
|
||||||
"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION,
|
|
||||||
}
|
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
client.put(upload_url, content=file_content, headers=request_headers),
|
client.put(upload_url, content=file_content, headers=headers),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
upload_time = time.perf_counter()
|
upload_time = time.perf_counter()
|
||||||
@ -283,170 +246,6 @@ class AzureBlobUploader:
|
|||||||
f"{type(e).__name__}: {e}")
|
f"{type(e).__name__}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _append_query(upload_url: str, **params: str) -> str:
|
|
||||||
"""SAS 쿼리를 유지하며 Azure REST API 쿼리를 추가합니다."""
|
|
||||||
separator = "&" if "?" in upload_url else "?"
|
|
||||||
return f"{upload_url}{separator}{urlencode(params)}"
|
|
||||||
|
|
||||||
async def _delete_upload_url(self, upload_url: str, log_prefix: str) -> bool:
|
|
||||||
"""실패한 업로드의 커밋/미커밋 Blob을 정리합니다."""
|
|
||||||
try:
|
|
||||||
client = await get_shared_blob_client()
|
|
||||||
response = await client.delete(
|
|
||||||
upload_url,
|
|
||||||
headers={"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION},
|
|
||||||
)
|
|
||||||
if response.status_code in {202, 404}:
|
|
||||||
return True
|
|
||||||
logger.warning(
|
|
||||||
f"[{log_prefix}] Blob cleanup failed - Status: "
|
|
||||||
f"{response.status_code}, Response: {response.text[:500]}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
f"[{log_prefix}] Blob cleanup error - {type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _upload_stream(
|
|
||||||
self,
|
|
||||||
chunks: AsyncIterator[bytes],
|
|
||||||
upload_url: str,
|
|
||||||
content_type: str,
|
|
||||||
timeout: float,
|
|
||||||
log_prefix: str,
|
|
||||||
*,
|
|
||||||
max_size_bytes: int | None = None,
|
|
||||||
expected_size_bytes: int | None = None,
|
|
||||||
cleanup_blob_on_failure: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Azure Block Blob API로 비동기 청크 스트림을 업로드합니다.
|
|
||||||
|
|
||||||
각 블록만 메모리에 유지하므로 파일 전체 크기와 무관하게 메모리 사용량이
|
|
||||||
일정합니다. 커밋 전 오류가 발생하면 업로드 대상 Blob 삭제를 시도합니다.
|
|
||||||
"""
|
|
||||||
block_ids: list[str] = []
|
|
||||||
block_id_nonce = os.urandom(16)
|
|
||||||
uploaded_size = 0
|
|
||||||
start_time = time.perf_counter()
|
|
||||||
|
|
||||||
async def cleanup_failed_stream() -> None:
|
|
||||||
# 기존 deterministic key는 미커밋 블록만 TTL 정리되게 두어 정상 Blob을 보존합니다.
|
|
||||||
if cleanup_blob_on_failure:
|
|
||||||
await self._delete_upload_url(upload_url, log_prefix)
|
|
||||||
|
|
||||||
try:
|
|
||||||
client = await get_shared_blob_client()
|
|
||||||
async with asyncio.timeout(timeout):
|
|
||||||
async for chunk in chunks:
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
|
|
||||||
uploaded_size += len(chunk)
|
|
||||||
if max_size_bytes is not None and uploaded_size > max_size_bytes:
|
|
||||||
raise BlobUploadTooLargeError(max_size_bytes)
|
|
||||||
|
|
||||||
raw_block_id = block_id_nonce + len(block_ids).to_bytes(4, "big")
|
|
||||||
block_id = base64.b64encode(raw_block_id).decode("ascii")
|
|
||||||
block_url = self._append_query(
|
|
||||||
upload_url,
|
|
||||||
comp="block",
|
|
||||||
blockid=block_id,
|
|
||||||
)
|
|
||||||
response = await client.put(
|
|
||||||
block_url,
|
|
||||||
content=chunk,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"x-ms-version": (
|
|
||||||
azure_blob_settings.AZURE_BLOB_API_VERSION
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if response.status_code != 201:
|
|
||||||
logger.error(
|
|
||||||
f"[{log_prefix}] Block upload failed - Status: "
|
|
||||||
f"{response.status_code}, Response: {response.text[:500]}"
|
|
||||||
)
|
|
||||||
await cleanup_failed_stream()
|
|
||||||
return False
|
|
||||||
block_ids.append(block_id)
|
|
||||||
|
|
||||||
if uploaded_size == 0:
|
|
||||||
logger.warning(f"[{log_prefix}] Empty upload stream")
|
|
||||||
await cleanup_failed_stream()
|
|
||||||
return False
|
|
||||||
|
|
||||||
if (
|
|
||||||
expected_size_bytes is not None
|
|
||||||
and uploaded_size != expected_size_bytes
|
|
||||||
):
|
|
||||||
logger.error(
|
|
||||||
f"[{log_prefix}] Stream size changed - expected: "
|
|
||||||
f"{expected_size_bytes}, actual: {uploaded_size}"
|
|
||||||
)
|
|
||||||
await cleanup_failed_stream()
|
|
||||||
return False
|
|
||||||
|
|
||||||
block_list = "".join(
|
|
||||||
f"<Latest>{block_id}</Latest>" for block_id in block_ids
|
|
||||||
)
|
|
||||||
commit_body = (
|
|
||||||
f'<?xml version="1.0" encoding="utf-8"?>'
|
|
||||||
f"<BlockList>{block_list}</BlockList>"
|
|
||||||
).encode("utf-8")
|
|
||||||
commit_url = self._append_query(upload_url, comp="blocklist")
|
|
||||||
response = await client.put(
|
|
||||||
commit_url,
|
|
||||||
content=commit_body,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/xml; charset=utf-8",
|
|
||||||
"x-ms-blob-content-type": content_type,
|
|
||||||
"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if response.status_code not in {200, 201}:
|
|
||||||
logger.error(
|
|
||||||
f"[{log_prefix}] Block list commit failed - Status: "
|
|
||||||
f"{response.status_code}, Response: {response.text[:500]}"
|
|
||||||
)
|
|
||||||
await cleanup_failed_stream()
|
|
||||||
return False
|
|
||||||
|
|
||||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
||||||
logger.info(
|
|
||||||
f"[{log_prefix}] SUCCESS - blocks: {len(block_ids)}, "
|
|
||||||
f"size: {uploaded_size} bytes, Duration: {duration_ms:.1f}ms"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except BlobUploadTooLargeError:
|
|
||||||
await cleanup_failed_stream()
|
|
||||||
raise
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# 클라이언트 연결 종료 중에도 가능한 범위에서 staged block을 정리합니다.
|
|
||||||
await asyncio.shield(cleanup_failed_stream())
|
|
||||||
raise
|
|
||||||
except TimeoutError:
|
|
||||||
elapsed = time.perf_counter() - start_time
|
|
||||||
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
elapsed = time.perf_counter() - start_time
|
|
||||||
logger.error(
|
|
||||||
f"[{log_prefix}] HTTP_ERROR after {elapsed:.1f}s - "
|
|
||||||
f"{type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
elapsed = time.perf_counter() - start_time
|
|
||||||
logger.error(
|
|
||||||
f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
|
||||||
f"{type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
await cleanup_failed_stream()
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _upload_file(
|
async def _upload_file(
|
||||||
self,
|
self,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
@ -474,20 +273,17 @@ class AzureBlobUploader:
|
|||||||
self._last_public_url = self._build_public_url(category, file_name)
|
self._last_public_url = self._build_public_url(category, file_name)
|
||||||
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||||
|
|
||||||
async def iter_file() -> AsyncIterator[bytes]:
|
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
||||||
async with aiofiles.open(file_path, "rb") as file:
|
|
||||||
while chunk := await file.read(
|
|
||||||
azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES
|
|
||||||
):
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
return await self._upload_stream(
|
async with aiofiles.open(file_path, "rb") as file:
|
||||||
chunks=iter_file(),
|
file_content = await file.read()
|
||||||
|
|
||||||
|
return await self._upload_bytes(
|
||||||
|
file_content=file_content,
|
||||||
upload_url=upload_url,
|
upload_url=upload_url,
|
||||||
content_type=content_type,
|
headers=headers,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
log_prefix=log_prefix,
|
log_prefix=log_prefix,
|
||||||
expected_size_bytes=Path(file_path).stat().st_size,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def upload_music(self, file_path: str) -> bool:
|
async def upload_music(self, file_path: str) -> bool:
|
||||||
@ -686,51 +482,6 @@ class AzureBlobUploader:
|
|||||||
log_prefix=log_prefix,
|
log_prefix=log_prefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def upload_image_stream(
|
|
||||||
self,
|
|
||||||
file: UploadFile,
|
|
||||||
file_name: str,
|
|
||||||
*,
|
|
||||||
expected_size_bytes: int | None = None,
|
|
||||||
max_size_bytes: int | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""FastAPI UploadFile을 Azure Block Blob으로 청크 업로드합니다."""
|
|
||||||
extension = Path(file_name).suffix.lower()
|
|
||||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
|
||||||
file_name = self._sanitize_filename(file_name)
|
|
||||||
|
|
||||||
upload_url = self._build_upload_url("image", file_name)
|
|
||||||
self._last_public_url = self._build_public_url("image", file_name)
|
|
||||||
log_prefix = "upload_image_stream"
|
|
||||||
chunk_size = azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES
|
|
||||||
max_size = (
|
|
||||||
max_size_bytes
|
|
||||||
if max_size_bytes is not None
|
|
||||||
else azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES
|
|
||||||
)
|
|
||||||
|
|
||||||
async def iter_upload() -> AsyncIterator[bytes]:
|
|
||||||
await file.seek(0)
|
|
||||||
while chunk := await file.read(chunk_size):
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
return await self._upload_stream(
|
|
||||||
chunks=iter_upload(),
|
|
||||||
upload_url=upload_url,
|
|
||||||
content_type=content_type,
|
|
||||||
timeout=60.0,
|
|
||||||
log_prefix=log_prefix,
|
|
||||||
max_size_bytes=max_size,
|
|
||||||
expected_size_bytes=expected_size_bytes,
|
|
||||||
cleanup_blob_on_failure=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def delete_image(self, file_name: str) -> bool:
|
|
||||||
"""이미지 Blob을 삭제합니다. DB 저장 실패 보상 처리용입니다."""
|
|
||||||
sanitized_name = self._sanitize_filename(file_name)
|
|
||||||
upload_url = self._build_upload_url("image", sanitized_name)
|
|
||||||
return await self._delete_upload_url(upload_url, "delete_image")
|
|
||||||
|
|
||||||
|
|
||||||
# 사용 예시:
|
# 사용 예시:
|
||||||
# import asyncio
|
# import asyncio
|
||||||
|
|||||||
@ -1,173 +0,0 @@
|
|||||||
"""영상 파일에서 SNS 공유용 포스터 이미지를 생성하고 저장합니다."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
||||||
|
|
||||||
logger = get_logger("video_poster")
|
|
||||||
|
|
||||||
FFMPEG_TIMEOUT_SECONDS = 30.0
|
|
||||||
FFMPEG_CLEANUP_TIMEOUT_SECONDS = 5.0
|
|
||||||
_STDERR_LOG_LIMIT = 500
|
|
||||||
|
|
||||||
|
|
||||||
async def _kill_and_wait(process: asyncio.subprocess.Process) -> None:
|
|
||||||
"""실행 중인 ffmpeg 프로세스를 종료하고 자원을 회수합니다."""
|
|
||||||
try:
|
|
||||||
process.kill()
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] ffmpeg 프로세스 종료에 실패했습니다: %s",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
process.wait(),
|
|
||||||
timeout=FFMPEG_CLEANUP_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] 종료한 ffmpeg 프로세스 회수 시간이 초과되었습니다 "
|
|
||||||
"(timeout=%ss)",
|
|
||||||
FFMPEG_CLEANUP_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] ffmpeg 프로세스 회수에 실패했습니다: %s",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_stderr(stderr: bytes) -> str:
|
|
||||||
"""ffmpeg 표준 오류를 로그에 안전한 길이의 문자열로 변환합니다."""
|
|
||||||
return stderr.decode("utf-8", errors="replace").strip()[-_STDERR_LOG_LIMIT:]
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_first_frame(video_path: str | Path) -> bytes | None:
|
|
||||||
"""로컬 영상의 첫 프레임을 JPEG 바이트로 추출하고 실패 시 ``None``을 반환합니다."""
|
|
||||||
process: asyncio.subprocess.Process | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
"ffmpeg",
|
|
||||||
"-nostdin",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-i",
|
|
||||||
str(video_path),
|
|
||||||
"-frames:v",
|
|
||||||
"1",
|
|
||||||
"-f",
|
|
||||||
"image2pipe",
|
|
||||||
"-c:v",
|
|
||||||
"mjpeg",
|
|
||||||
"pipe:1",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
stdout, stderr = await asyncio.wait_for(
|
|
||||||
process.communicate(),
|
|
||||||
timeout=FFMPEG_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
|
||||||
await _kill_and_wait(process)
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] ffmpeg 첫 프레임 추출 시간이 초과되었습니다 "
|
|
||||||
"(path=%s, timeout=%ss)",
|
|
||||||
video_path,
|
|
||||||
FFMPEG_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if process.returncode != 0:
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] ffmpeg 첫 프레임 추출에 실패했습니다 "
|
|
||||||
"(path=%s, returncode=%s, stderr=%s)",
|
|
||||||
video_path,
|
|
||||||
process.returncode,
|
|
||||||
_format_stderr(stderr),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not stdout:
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] ffmpeg가 빈 이미지를 반환했습니다 (path=%s)",
|
|
||||||
video_path,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return stdout
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
if process is not None:
|
|
||||||
await _kill_and_wait(process)
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
if process is not None:
|
|
||||||
await _kill_and_wait(process)
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] 첫 프레임 추출 중 오류가 발생했습니다 "
|
|
||||||
"(path=%s, error=%s: %s)",
|
|
||||||
video_path,
|
|
||||||
type(exc).__name__,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_and_store_poster(
|
|
||||||
*,
|
|
||||||
video_path: str | Path,
|
|
||||||
user_uuid: str,
|
|
||||||
task_id: str,
|
|
||||||
file_stem: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""첫 프레임을 Blob에 저장하고 공개 URL을 반환하며, 실패 시 ``None``을 반환합니다."""
|
|
||||||
try:
|
|
||||||
image_bytes = await extract_first_frame(video_path)
|
|
||||||
if image_bytes is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
|
|
||||||
uploaded = await uploader.upload_image_bytes(
|
|
||||||
image_bytes,
|
|
||||||
f"{file_stem}.jpg",
|
|
||||||
)
|
|
||||||
if not uploaded:
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] 포스터 Blob 업로드에 실패했습니다 "
|
|
||||||
"(path=%s, task_id=%s)",
|
|
||||||
video_path,
|
|
||||||
task_id,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not uploader.public_url:
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] 포스터 업로드 후 공개 URL이 비어 있습니다 "
|
|
||||||
"(path=%s, task_id=%s)",
|
|
||||||
video_path,
|
|
||||||
task_id,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return uploader.public_url
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"[video_poster] 포스터 생성 또는 저장 중 오류가 발생했습니다 "
|
|
||||||
"(path=%s, task_id=%s, error=%s: %s)",
|
|
||||||
video_path,
|
|
||||||
task_id,
|
|
||||||
type(exc).__name__,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
@ -17,8 +17,7 @@ import json
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@ -30,7 +29,6 @@ from app.utils.pagination import PaginatedResponse
|
|||||||
from app.home.models import Image, Project, MarketingIntel
|
from app.home.models import Image, Project, MarketingIntel
|
||||||
from app.home.api.routers.v1.home import _extract_region_from_address
|
from app.home.api.routers.v1.home import _extract_region_from_address
|
||||||
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
|
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
|
||||||
from app.utils.upload_blob_as_request import to_playback_url
|
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song, SongTimestamp
|
from app.song.models import Song, SongTimestamp
|
||||||
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
|
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
|
||||||
@ -59,69 +57,16 @@ from app.video.schemas.video_schema import (
|
|||||||
VideoRenderData,
|
VideoRenderData,
|
||||||
VideoThumbnailItem,
|
VideoThumbnailItem,
|
||||||
)
|
)
|
||||||
from app.video.services.share_page import (
|
|
||||||
build_video_share_html,
|
|
||||||
get_video_share_data,
|
|
||||||
resolve_frontend_base_url,
|
|
||||||
resolve_share_url,
|
|
||||||
)
|
|
||||||
from app.video.worker.video_task import download_and_upload_video_to_blob
|
from app.video.worker.video_task import download_and_upload_video_to_blob
|
||||||
|
|
||||||
|
|
||||||
from config import creatomate_settings, prj_settings
|
from config import creatomate_settings
|
||||||
|
|
||||||
logger = get_logger("video")
|
logger = get_logger("video")
|
||||||
|
|
||||||
router = APIRouter(prefix="/video", tags=["Video"])
|
router = APIRouter(prefix="/video", tags=["Video"])
|
||||||
|
|
||||||
|
|
||||||
def _place_id_to_site_url(place_id: str | None) -> str | None:
|
|
||||||
"""MarketingIntel.place_id("nv{네이버 place ID}")를 네이버 플레이스 URL로 변환한다.
|
|
||||||
|
|
||||||
크롤링 없이 직접 입력된 업체는 place_id가 없으므로 None을 반환한다.
|
|
||||||
"""
|
|
||||||
if place_id and place_id.startswith("nv") and place_id[2:].isdigit():
|
|
||||||
return f"https://map.naver.com/p/entry/place/{place_id[2:]}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_official_site_urls(
|
|
||||||
session: AsyncSession, projects: list[Project]
|
|
||||||
) -> dict[int, str | None]:
|
|
||||||
"""프로젝트 목록에 대해 {project_id: 공식 페이지 URL(or None)}을 일괄 조회한다.
|
|
||||||
|
|
||||||
Project.marketing_intelligence(문자열로 저장된 MarketingIntel.id)를 경유해
|
|
||||||
저장된 official_site_url을 우선 사용하고, 컬럼 도입 전 기존 행은
|
|
||||||
place_id 기반 네이버 플레이스 URL로 폴백한다.
|
|
||||||
"""
|
|
||||||
m_id_by_project: dict[int, int] = {}
|
|
||||||
for p in projects:
|
|
||||||
try:
|
|
||||||
if p.marketing_intelligence is not None:
|
|
||||||
m_id_by_project[p.id] = int(p.marketing_intelligence)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
url_by_project: dict[int, str | None] = {p.id: None for p in projects}
|
|
||||||
if not m_id_by_project:
|
|
||||||
return url_by_project
|
|
||||||
|
|
||||||
rows = (
|
|
||||||
await session.execute(
|
|
||||||
select(
|
|
||||||
MarketingIntel.id,
|
|
||||||
MarketingIntel.place_id,
|
|
||||||
MarketingIntel.official_site_url,
|
|
||||||
).where(MarketingIntel.id.in_(set(m_id_by_project.values())))
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
intel_by_m_id = {m_id: (place_id, site_url) for m_id, place_id, site_url in rows}
|
|
||||||
|
|
||||||
for project_id, m_id in m_id_by_project.items():
|
|
||||||
place_id, site_url = intel_by_m_id.get(m_id, (None, None))
|
|
||||||
url_by_project[project_id] = site_url or _place_id_to_site_url(place_id)
|
|
||||||
return url_by_project
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/generate/{task_id}",
|
"/generate/{task_id}",
|
||||||
@ -861,7 +806,7 @@ async def download_video(
|
|||||||
store_name=project.store_name if project else None,
|
store_name=project.store_name if project else None,
|
||||||
region=project.region or _extract_region_from_address(project.detail_region_info) if project else None,
|
region=project.region or _extract_region_from_address(project.detail_region_info) if project else None,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
result_movie_url=to_playback_url(video.result_movie_url),
|
result_movie_url=video.result_movie_url,
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1037,21 +982,15 @@ async def get_all_videos(
|
|||||||
|
|
||||||
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
|
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
|
||||||
|
|
||||||
official_site_url_map = await _get_official_site_urls(
|
|
||||||
session, [p for _, p, _ in rows]
|
|
||||||
)
|
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
VideoThumbnailItem(
|
VideoThumbnailItem(
|
||||||
video_id=v.id,
|
video_id=v.id,
|
||||||
store_name=p.store_name,
|
store_name=p.store_name,
|
||||||
result_movie_url=to_playback_url(v.result_movie_url),
|
result_movie_url=v.result_movie_url,
|
||||||
poster_url=v.poster_url,
|
|
||||||
created_at=v.created_at,
|
created_at=v.created_at,
|
||||||
like_count=like_count_map.get(v.id) or 0,
|
like_count=like_count_map.get(v.id) or 0,
|
||||||
is_liked_by_me=liked_map.get(v.id, False),
|
is_liked_by_me=liked_map.get(v.id, False),
|
||||||
comment_count=comment_count or 0,
|
comment_count=comment_count or 0,
|
||||||
official_site_url=official_site_url_map.get(p.id),
|
|
||||||
)
|
)
|
||||||
for v, p, comment_count in rows
|
for v, p, comment_count in rows
|
||||||
]
|
]
|
||||||
@ -1151,50 +1090,6 @@ async def toggle_like(
|
|||||||
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/share/{video_id}",
|
|
||||||
response_class=HTMLResponse,
|
|
||||||
summary="영상 공유용 Open Graph 페이지",
|
|
||||||
description="영상별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.",
|
|
||||||
responses={
|
|
||||||
200: {"description": "공유 메타데이터 HTML 반환"},
|
|
||||||
404: {"description": "공유 가능한 완료 영상을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_video_share_page(
|
|
||||||
video_id: int,
|
|
||||||
request: Request,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> HTMLResponse:
|
|
||||||
"""공개 공유 페이지를 반환하고 일반 브라우저는 영상 상세로 이동시킵니다."""
|
|
||||||
share_data = await get_video_share_data(session, video_id)
|
|
||||||
if share_data is None:
|
|
||||||
raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
share_url = resolve_share_url(
|
|
||||||
request.headers,
|
|
||||||
str(request.url).split("?", maxsplit=1)[0],
|
|
||||||
prj_settings.SHARE_API_BASE_URL,
|
|
||||||
)
|
|
||||||
html = build_video_share_html(
|
|
||||||
share_data,
|
|
||||||
share_url=share_url,
|
|
||||||
frontend_base_url=resolve_frontend_base_url(
|
|
||||||
request.headers,
|
|
||||||
prj_settings.SHARE_FRONTEND_URL,
|
|
||||||
),
|
|
||||||
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
|
|
||||||
)
|
|
||||||
return HTMLResponse(
|
|
||||||
content=html,
|
|
||||||
headers={
|
|
||||||
"Cache-Control": "public, max-age=300",
|
|
||||||
"Referrer-Policy": "no-referrer",
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{video_id}",
|
"/{video_id}",
|
||||||
summary="단일 영상 상세 조회",
|
summary="단일 영상 상세 조회",
|
||||||
@ -1267,21 +1162,15 @@ async def get_video_detail(
|
|||||||
liked = False
|
liked = False
|
||||||
is_liked_by_me = liked
|
is_liked_by_me = liked
|
||||||
|
|
||||||
official_site_url_map = await _get_official_site_urls(session, [project])
|
|
||||||
|
|
||||||
logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
|
logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
|
||||||
return VideoDetailResponse(
|
return VideoDetailResponse(
|
||||||
video_id=video.id,
|
video_id=video.id,
|
||||||
result_movie_url=to_playback_url(video.result_movie_url),
|
result_movie_url=video.result_movie_url,
|
||||||
poster_url=video.poster_url,
|
|
||||||
store_name=project.store_name,
|
store_name=project.store_name,
|
||||||
region=project.region or _extract_region_from_address(project.detail_region_info),
|
region=project.region or _extract_region_from_address(project.detail_region_info),
|
||||||
title=video.title,
|
|
||||||
description=video.description,
|
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
like_count=like_count,
|
like_count=like_count,
|
||||||
is_liked_by_me=is_liked_by_me,
|
is_liked_by_me=is_liked_by_me,
|
||||||
official_site_url=official_site_url_map.get(project.id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
|
||||||
from sqlalchemy.dialects.mysql import JSON
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
@ -30,10 +29,6 @@ class Video(Base):
|
|||||||
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
|
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||||
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
||||||
poster_url: 영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)
|
|
||||||
title: SNS 업로드 제목
|
|
||||||
description: SNS 업로드 설명
|
|
||||||
hashtags: SNS 해시태그 목록
|
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
@ -111,30 +106,6 @@ class Video(Base):
|
|||||||
comment="생성된 영상 URL",
|
comment="생성된 영상 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
poster_url: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(2048),
|
|
||||||
nullable=True,
|
|
||||||
comment="영상 첫 프레임 포스터 이미지 URL (SNS 공유용)",
|
|
||||||
)
|
|
||||||
|
|
||||||
title: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(100),
|
|
||||||
nullable=True,
|
|
||||||
comment="SNS 업로드 제목",
|
|
||||||
)
|
|
||||||
|
|
||||||
description: Mapped[Optional[str]] = mapped_column(
|
|
||||||
Text,
|
|
||||||
nullable=True,
|
|
||||||
comment="SNS 업로드 설명",
|
|
||||||
)
|
|
||||||
|
|
||||||
hashtags: Mapped[Optional[list]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=True,
|
|
||||||
comment="SNS 해시태그 목록",
|
|
||||||
)
|
|
||||||
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
Boolean,
|
Boolean,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@ -5,7 +5,7 @@ Video API Schemas
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@ -148,7 +148,6 @@ class VideoListItem(BaseModel):
|
|||||||
"region": "군산",
|
"region": "군산",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||||
"poster_url": "http://localhost:8000/media/2025-01-15/video.jpg",
|
|
||||||
"created_at": "2025-01-15T12:00:00"
|
"created_at": "2025-01-15T12:00:00"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@ -158,17 +157,9 @@ class VideoListItem(BaseModel):
|
|||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
|
||||||
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
|
||||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
|
||||||
hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록")
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
like_count: int = Field(0, description="좋아요 수")
|
like_count: int = Field(0, description="좋아요 수")
|
||||||
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
|
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
|
||||||
is_liked_by_me: bool = Field(
|
|
||||||
False,
|
|
||||||
description="현재 로그인 사용자가 좋아요를 눌렀는지",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VideoThumbnailItem(BaseModel):
|
class VideoThumbnailItem(BaseModel):
|
||||||
@ -180,16 +171,11 @@ class VideoThumbnailItem(BaseModel):
|
|||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
|
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
|
||||||
store_name: str = Field(..., description="업체명")
|
store_name: str = Field(..., description="업체명")
|
||||||
result_movie_url: str = Field(..., description="영상 URL")
|
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
|
||||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
|
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
like_count: int = Field(..., description="좋아요 수")
|
like_count: int = Field(..., description="좋아요 수")
|
||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||||
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
|
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
|
||||||
official_site_url: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VideoDetailResponse(BaseModel):
|
class VideoDetailResponse(BaseModel):
|
||||||
@ -201,18 +187,11 @@ class VideoDetailResponse(BaseModel):
|
|||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
video_id: int = Field(..., description="영상 고유 ID")
|
||||||
result_movie_url: str = Field(..., description="영상 URL")
|
result_movie_url: str = Field(..., description="영상 URL")
|
||||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
title: Optional[str] = Field(None, description="SNS 업로드 제목 (공유 시 og:title 및 공유 제목으로 사용)")
|
|
||||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
like_count: int = Field(..., description="좋아요 수")
|
like_count: int = Field(..., description="좋아요 수")
|
||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||||
official_site_url: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LikeToggleResponse(BaseModel):
|
class LikeToggleResponse(BaseModel):
|
||||||
|
|||||||
@ -1,321 +0,0 @@
|
|||||||
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from html import escape
|
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.home.models import Project
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
|
|
||||||
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
|
|
||||||
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class VideoShareData:
|
|
||||||
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
|
|
||||||
|
|
||||||
video_id: int
|
|
||||||
poster_url: str | None
|
|
||||||
store_name: str
|
|
||||||
region: str
|
|
||||||
title: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_video_share_data(
|
|
||||||
session: AsyncSession,
|
|
||||||
video_id: int,
|
|
||||||
) -> VideoShareData | None:
|
|
||||||
"""공유 가능한 완료 영상을 프로젝트 정보와 함께 조회합니다."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(
|
|
||||||
Video.id,
|
|
||||||
Video.poster_url,
|
|
||||||
Video.title,
|
|
||||||
Video.description,
|
|
||||||
Project.store_name,
|
|
||||||
Project.region,
|
|
||||||
)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted.is_(False),
|
|
||||||
Project.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.one_or_none()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return VideoShareData(
|
|
||||||
video_id=row.id,
|
|
||||||
poster_url=row.poster_url,
|
|
||||||
store_name=row.store_name,
|
|
||||||
region=row.region,
|
|
||||||
title=row.title,
|
|
||||||
description=row.description,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_video_share_html(
|
|
||||||
data: VideoShareData,
|
|
||||||
*,
|
|
||||||
share_url: str,
|
|
||||||
frontend_base_url: str,
|
|
||||||
configured_default_image_url: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
|
|
||||||
frontend_base = _normalise_frontend_base_url(frontend_base_url)
|
|
||||||
detail_url = f"{frontend_base}/video/{data.video_id}"
|
|
||||||
fallback_image_url = _resolve_default_image_url(
|
|
||||||
configured_default_image_url,
|
|
||||||
frontend_base,
|
|
||||||
share_url=share_url,
|
|
||||||
)
|
|
||||||
image_url = _absolute_http_url(data.poster_url) or fallback_image_url
|
|
||||||
|
|
||||||
title = _share_title(data.title, data.store_name)
|
|
||||||
description = _share_description(data.description)
|
|
||||||
canonical_tags = _canonical_tags(_absolute_http_url(share_url))
|
|
||||||
image_size_tags = _og_image_size_tags(image_url, fallback_image_url)
|
|
||||||
|
|
||||||
escaped_title = escape(title, quote=True)
|
|
||||||
escaped_description = escape(description, quote=True)
|
|
||||||
escaped_image_url = escape(image_url, quote=True)
|
|
||||||
escaped_detail_url = escape(detail_url, quote=True)
|
|
||||||
|
|
||||||
return f"""<!doctype html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{escaped_title}</title>
|
|
||||||
<meta name="description" content="{escaped_description}">
|
|
||||||
{canonical_tags}
|
|
||||||
<meta property="og:title" content="{escaped_title}">
|
|
||||||
<meta property="og:description" content="{escaped_description}">
|
|
||||||
<meta property="og:image" content="{escaped_image_url}">
|
|
||||||
<meta property="og:image:alt" content="{escaped_title}">
|
|
||||||
{image_size_tags} <meta property="og:type" content="website">
|
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
<meta name="twitter:title" content="{escaped_title}">
|
|
||||||
<meta name="twitter:description" content="{escaped_description}">
|
|
||||||
<meta name="twitter:image" content="{escaped_image_url}">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>{escaped_title}</h1>
|
|
||||||
<p>{escaped_description}</p>
|
|
||||||
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
(function () {{
|
|
||||||
var ua = navigator.userAgent || "";
|
|
||||||
// 앱 이름만으로 판별하면 인앱 브라우저(예: KAKAOTALK)까지 크롤러로 잡혀
|
|
||||||
// 사용자가 중간 페이지에 멈춘다. 봇 전용 토큰만 쓴다.
|
|
||||||
if (/bot|crawl|spider|slurp|facebookexternalhit|Facebot|Twitterbot|LinkedInBot|Pinterestbot|Slackbot|TelegramBot|WhatsApp|Discordbot|kakaotalk-scrap|Embedly|redditbot|Applebot/i.test(ua)) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
window.location.replace(document.getElementById("continue-link").href);
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _normalise_text(value: str | None, fallback: str) -> str:
|
|
||||||
"""메타데이터용 텍스트에서 불필요한 공백을 제거합니다."""
|
|
||||||
normalised = " ".join((value or "").split())
|
|
||||||
return normalised or fallback
|
|
||||||
|
|
||||||
|
|
||||||
_OG_DESCRIPTION_MAX_LEN = 300
|
|
||||||
|
|
||||||
|
|
||||||
def _share_title(title: str | None, store_name: str) -> str:
|
|
||||||
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
|
|
||||||
stored = _normalise_text(title, "")
|
|
||||||
if stored:
|
|
||||||
return stored
|
|
||||||
name = _normalise_text(store_name, "ADO2 영상")
|
|
||||||
return f"{name} | ADO2"
|
|
||||||
|
|
||||||
|
|
||||||
def _share_description(description: str | None) -> str:
|
|
||||||
"""저장된 SNS 설명을 쓰고, 없으면 기본 문구를 사용합니다."""
|
|
||||||
stored = _normalise_text(description, "")
|
|
||||||
if stored:
|
|
||||||
if len(stored) > _OG_DESCRIPTION_MAX_LEN:
|
|
||||||
return stored[: _OG_DESCRIPTION_MAX_LEN - 1].rstrip() + "…"
|
|
||||||
return stored
|
|
||||||
return "ADO2 AI 마케팅 영상"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_frontend_base_url(headers: Mapping[str, str], fallback: str) -> str:
|
|
||||||
"""프록시가 넘긴 프론트 호스트를 우선해 canonical 기준 URL을 정합니다."""
|
|
||||||
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
|
|
||||||
if not forwarded_host:
|
|
||||||
return _normalise_frontend_base_url(fallback)
|
|
||||||
|
|
||||||
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
|
|
||||||
if forwarded_proto not in {"http", "https"}:
|
|
||||||
forwarded_proto = "https"
|
|
||||||
return _normalise_frontend_base_url(f"{forwarded_proto}://{forwarded_host}")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_share_url(
|
|
||||||
headers: Mapping[str, str],
|
|
||||||
request_url: str,
|
|
||||||
configured_api_base_url: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""크롤러가 다시 읽어도 같은 OG 페이지가 나오는 공유 URL을 만듭니다.
|
|
||||||
|
|
||||||
nginx가 ``/api`` prefix를 떼고 넘기면 ``request.url``에는 그 prefix가 없어,
|
|
||||||
그대로 쓰면 프론트 SPA 주소가 된다. 복원할 근거가 없으면 빈 문자열을 돌려
|
|
||||||
호출부가 canonical/og:url을 생략하도록 한다.
|
|
||||||
"""
|
|
||||||
path = urlsplit(request_url).path
|
|
||||||
|
|
||||||
configured_base = _absolute_http_url(configured_api_base_url)
|
|
||||||
if configured_base:
|
|
||||||
return f"{configured_base.rstrip('/')}{path}"
|
|
||||||
|
|
||||||
origin = _forwarded_origin(headers) or _origin_from_url(request_url)
|
|
||||||
if not origin:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
forwarded_prefix = (headers.get("x-forwarded-prefix") or "").strip().rstrip("/")
|
|
||||||
if forwarded_prefix:
|
|
||||||
return f"{origin}{forwarded_prefix}{path}"
|
|
||||||
|
|
||||||
if headers.get("x-forwarded-host"):
|
|
||||||
# 프록시 뒤인데 prefix 를 못 받았다. 잘못된 URL 을 내보내지 않는다.
|
|
||||||
return ""
|
|
||||||
|
|
||||||
return f"{origin}{path}"
|
|
||||||
|
|
||||||
|
|
||||||
def _forwarded_origin(headers: Mapping[str, str]) -> str | None:
|
|
||||||
"""프록시가 넘긴 외부 호스트 기준 origin을 만듭니다."""
|
|
||||||
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
|
|
||||||
if not forwarded_host:
|
|
||||||
return None
|
|
||||||
|
|
||||||
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
|
|
||||||
if forwarded_proto not in {"http", "https"}:
|
|
||||||
forwarded_proto = "https"
|
|
||||||
return f"{forwarded_proto}://{forwarded_host}"
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_tags(canonical_url: str | None) -> str:
|
|
||||||
"""공유 URL을 확신할 때만 canonical/og:url을 붙입니다.
|
|
||||||
|
|
||||||
잘못된 og:url을 내보내면 크롤러가 그 주소를 다시 읽어, OG 메타가 없는
|
|
||||||
프론트 SPA 문서를 미리보기로 쓴다. 확신이 없으면 크롤러가 실제로 받은
|
|
||||||
URL을 쓰도록 태그 자체를 생략한다.
|
|
||||||
"""
|
|
||||||
if not canonical_url:
|
|
||||||
return ""
|
|
||||||
escaped = escape(canonical_url, quote=True)
|
|
||||||
return (
|
|
||||||
f' <link rel="canonical" href="{escaped}">\n'
|
|
||||||
f' <meta property="og:url" content="{escaped}">\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_OG_IMAGE_SIZE = (385, 385)
|
|
||||||
|
|
||||||
|
|
||||||
def _og_image_size_tags(image_url: str, fallback_image_url: str) -> str:
|
|
||||||
"""폴백 로고처럼 크기를 아는 이미지에만 width/height 메타를 붙입니다."""
|
|
||||||
if image_url != fallback_image_url:
|
|
||||||
return ""
|
|
||||||
width, height = _DEFAULT_OG_IMAGE_SIZE
|
|
||||||
return (
|
|
||||||
f' <meta property="og:image:width" content="{width}">\n'
|
|
||||||
f' <meta property="og:image:height" content="{height}">\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalise_frontend_base_url(value: str) -> str:
|
|
||||||
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
|
|
||||||
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL
|
|
||||||
parts = urlsplit(absolute_url)
|
|
||||||
path = parts.path.rstrip("/")
|
|
||||||
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_default_image_url(
|
|
||||||
configured_url: str,
|
|
||||||
frontend_base: str,
|
|
||||||
*,
|
|
||||||
share_url: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""포스터가 없을 때 사용할 기본 OG 이미지 URL을 반환합니다.
|
|
||||||
|
|
||||||
우선순위:
|
|
||||||
1. ``SHARE_DEFAULT_IMAGE_URL`` (.env)
|
|
||||||
2. 공유 URL과 같은 API 베이스 ``.../static/images/ado2_image.png``
|
|
||||||
(``/api/video/share/1`` 이면 ``/api/static/...``)
|
|
||||||
3. ``SHARE_FRONTEND_URL`` + ``/assets/images/ado2_image.png``
|
|
||||||
"""
|
|
||||||
configured_absolute_url = _absolute_http_url(configured_url)
|
|
||||||
if configured_absolute_url:
|
|
||||||
return configured_absolute_url
|
|
||||||
|
|
||||||
share_api_base = _api_base_from_share_url(share_url)
|
|
||||||
if share_api_base:
|
|
||||||
return f"{share_api_base}{DEFAULT_SHARE_IMAGE_STATIC_PATH}"
|
|
||||||
|
|
||||||
return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}"
|
|
||||||
|
|
||||||
|
|
||||||
def _api_base_from_share_url(share_url: str) -> str | None:
|
|
||||||
"""공유 URL에서 API 베이스를 만듭니다. ``/api/video/share/1`` → ``https://host/api``."""
|
|
||||||
absolute_url = _absolute_http_url(share_url)
|
|
||||||
if not absolute_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
parts = urlsplit(absolute_url)
|
|
||||||
origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
|
||||||
idx = (parts.path or "").find("/video/share/")
|
|
||||||
if idx < 0:
|
|
||||||
return origin
|
|
||||||
|
|
||||||
prefix = parts.path[:idx].rstrip("/")
|
|
||||||
return f"{origin}{prefix}" if prefix else origin
|
|
||||||
|
|
||||||
|
|
||||||
def _origin_from_url(value: str) -> str | None:
|
|
||||||
"""URL에서 scheme + host(+port) origin만 추출합니다."""
|
|
||||||
absolute_url = _absolute_http_url(value)
|
|
||||||
if not absolute_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
parts = urlsplit(absolute_url)
|
|
||||||
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _absolute_http_url(value: str | None) -> str | None:
|
|
||||||
"""값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다."""
|
|
||||||
candidate = (value or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
parts = urlsplit(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if parts.scheme.lower() not in {"http", "https"} or not parts.hostname:
|
|
||||||
return None
|
|
||||||
return candidate
|
|
||||||
@ -4,6 +4,7 @@ Video Background Tasks
|
|||||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@ -16,7 +17,6 @@ from app.user.services.credit import consume_credit
|
|||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.video_poster import generate_and_store_poster
|
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("video")
|
logger = get_logger("video")
|
||||||
@ -30,8 +30,7 @@ async def _update_video_status(
|
|||||||
status: str,
|
status: str,
|
||||||
video_url: str | None = None,
|
video_url: str | None = None,
|
||||||
creatomate_render_id: str | None = None,
|
creatomate_render_id: str | None = None,
|
||||||
poster_url: str | None = None,
|
) -> bool:
|
||||||
) -> int | None:
|
|
||||||
"""Video 테이블의 상태를 업데이트합니다.
|
"""Video 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -39,10 +38,9 @@ async def _update_video_status(
|
|||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
status: 변경할 상태 ("processing", "completed", "failed")
|
||||||
video_url: 영상 URL
|
video_url: 영상 URL
|
||||||
creatomate_render_id: Creatomate render ID (선택)
|
creatomate_render_id: Creatomate render ID (선택)
|
||||||
poster_url: 영상 첫 프레임 포스터 URL (선택)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int | None: 업데이트된 Video id. 대상이 없거나 실패하면 None.
|
bool: 업데이트 성공 여부
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
@ -67,58 +65,19 @@ async def _update_video_status(
|
|||||||
video.status = status
|
video.status = status
|
||||||
if video_url is not None:
|
if video_url is not None:
|
||||||
video.result_movie_url = video_url
|
video.result_movie_url = video_url
|
||||||
if poster_url is not None:
|
|
||||||
video.poster_url = poster_url
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
||||||
return video.id
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
||||||
return None
|
return False
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||||
return None
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||||
return None
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _try_generate_sns_metadata(video_id: int) -> None:
|
|
||||||
"""SEO 생성 실패가 영상 완료 처리에 영향을 주지 않도록 격리합니다."""
|
|
||||||
from app.social.services.seo_service import seo_service
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
await seo_service.generate_and_save_for_video(video_id, session)
|
|
||||||
await session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"[VideoSEO] Failed to generate SNS metadata - video_id: {video_id}, error: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _try_generate_poster(
|
|
||||||
temp_file_path: Path,
|
|
||||||
user_uuid: str,
|
|
||||||
task_id: str,
|
|
||||||
render_id: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""포스터 생성 실패가 영상 생성 완료 처리에 영향을 주지 않도록 격리합니다."""
|
|
||||||
try:
|
|
||||||
return await generate_and_store_poster(
|
|
||||||
video_path=temp_file_path,
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
task_id=task_id,
|
|
||||||
file_stem=render_id,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"[VideoPoster] Failed to generate poster - task_id: {task_id}, render_id: {render_id}, error: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _download_video(url: str, task_id: str) -> bytes:
|
async def _download_video(url: str, task_id: str) -> bytes:
|
||||||
@ -194,20 +153,8 @@ async def download_and_upload_video_to_blob(
|
|||||||
blob_url = uploader.public_url
|
blob_url = uploader.public_url
|
||||||
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||||
|
|
||||||
poster_url = await _try_generate_poster(
|
|
||||||
temp_file_path, user_uuid, task_id, creatomate_render_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
||||||
video_id = await _update_video_status(
|
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
|
||||||
task_id,
|
|
||||||
"completed",
|
|
||||||
blob_url,
|
|
||||||
creatomate_render_id,
|
|
||||||
poster_url=poster_url,
|
|
||||||
)
|
|
||||||
if video_id is not None:
|
|
||||||
await _try_generate_sns_metadata(video_id)
|
|
||||||
|
|
||||||
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
@ -312,20 +259,13 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||||||
blob_url = uploader.public_url
|
blob_url = uploader.public_url
|
||||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||||
|
|
||||||
poster_url = await _try_generate_poster(
|
|
||||||
temp_file_path, user_uuid, task_id, creatomate_render_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Video 테이블 업데이트
|
# Video 테이블 업데이트
|
||||||
video_id = await _update_video_status(
|
await _update_video_status(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
status="completed",
|
status="completed",
|
||||||
video_url=blob_url,
|
video_url=blob_url,
|
||||||
creatomate_render_id=creatomate_render_id,
|
creatomate_render_id=creatomate_render_id,
|
||||||
poster_url=poster_url,
|
|
||||||
)
|
)
|
||||||
if video_id is not None:
|
|
||||||
await _try_generate_sns_metadata(video_id)
|
|
||||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
|
|||||||
58
config.py
58
config.py
@ -33,25 +33,6 @@ class ProjectSettings(BaseSettings):
|
|||||||
ADMIN_BASE_URL: str = Field(default="/admin")
|
ADMIN_BASE_URL: str = Field(default="/admin")
|
||||||
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
|
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
|
||||||
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
|
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
|
||||||
SHARE_FRONTEND_URL: str = Field(
|
|
||||||
default="https://ado2.o2osolution.ai",
|
|
||||||
description="공유 페이지에서 영상 상세로 이동할 프론트엔드 공개 기준 URL (.env: SHARE_FRONTEND_URL)",
|
|
||||||
)
|
|
||||||
SHARE_API_BASE_URL: str = Field(
|
|
||||||
default="",
|
|
||||||
description=(
|
|
||||||
"공유 OG 페이지가 외부에 노출되는 API 기준 URL (.env: SHARE_API_BASE_URL). "
|
|
||||||
"예: https://dev-ssul.castad.net/api. 프록시가 /api prefix 를 떼고 넘기면 "
|
|
||||||
"request.url 로는 복원할 수 없으므로 이 값이 필요하다"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
SHARE_DEFAULT_IMAGE_URL: str = Field(
|
|
||||||
default="",
|
|
||||||
description=(
|
|
||||||
"포스터가 없는 영상 공유 시 사용할 절대 이미지 URL (.env: SHARE_DEFAULT_IMAGE_URL). "
|
|
||||||
"비우면 공유 API /static/images/ado2_image.png, 없으면 SHARE_FRONTEND_URL/assets 경로 사용"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
DEBUG: bool = Field(default=True)
|
DEBUG: bool = Field(default=True)
|
||||||
TIMEZONE: str = Field(
|
TIMEZONE: str = Field(
|
||||||
default="Asia/Seoul",
|
default="Asia/Seoul",
|
||||||
@ -164,40 +145,6 @@ class AzureBlobSettings(BaseSettings):
|
|||||||
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
||||||
description="Azure Blob Storage 기본 URL",
|
description="Azure Blob Storage 기본 URL",
|
||||||
)
|
)
|
||||||
AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES: int = Field(
|
|
||||||
default=4 * 1024 * 1024,
|
|
||||||
gt=0,
|
|
||||||
description="Azure Block Blob 업로드 블록 크기 (bytes)",
|
|
||||||
)
|
|
||||||
AZURE_BLOB_API_VERSION: str = Field(
|
|
||||||
default="2023-11-03",
|
|
||||||
description="Azure Blob Storage REST API x-ms-version",
|
|
||||||
)
|
|
||||||
IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES: int = Field(
|
|
||||||
default=15 * 1024 * 1024,
|
|
||||||
gt=0,
|
|
||||||
description="이미지 업로드 파일 1개당 최대 크기 (bytes)",
|
|
||||||
)
|
|
||||||
IMAGE_UPLOAD_MAX_REQUEST_SIZE_BYTES: int = Field(
|
|
||||||
default=20 * 1024 * 1024,
|
|
||||||
gt=0,
|
|
||||||
description="한 이미지 업로드 요청에 포함할 수 있는 파일 합계 최대 크기 (bytes)",
|
|
||||||
)
|
|
||||||
IMAGE_UPLOAD_LOCK_TIMEOUT_SECONDS: int = Field(
|
|
||||||
default=15,
|
|
||||||
ge=1,
|
|
||||||
description="동일 task 이미지 append 직렬화 락 대기 시간 (초)",
|
|
||||||
)
|
|
||||||
IMAGE_UPLOAD_MAX_CONCURRENT_LOCKS: int = Field(
|
|
||||||
default=10,
|
|
||||||
ge=1,
|
|
||||||
description="worker별 이미지 upload named lock 동시 점유 상한",
|
|
||||||
)
|
|
||||||
IMAGE_UPLOAD_MAX_TASK_IMAGES: int = Field(
|
|
||||||
default=100,
|
|
||||||
ge=1,
|
|
||||||
description="한 task에 누적할 수 있는 활성 이미지 최대 개수",
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
@ -228,7 +175,10 @@ class CreatomateSettings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
description="Creatomate 자체 자동 가사 생성 기능 사용 여부",
|
description="Creatomate 자체 자동 가사 생성 기능 사용 여부",
|
||||||
)
|
)
|
||||||
LYRIC_SUBTITLE: bool = Field(default=False, description="영상 가사 표기 여부")
|
LYRIC_SUBTITLE: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="영상 가사 표기 여부"
|
||||||
|
)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: video 테이블에 poster_url 컬럼 추가
|
|
||||||
-- Date: 2026-08-13
|
|
||||||
-- Description: 영상 첫 프레임 포스터 이미지 URL. SNS 공유 og:image 용도.
|
|
||||||
-- 관련 코드: app/utils/video_poster.py, app/video/worker/video_task.py
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE `video`
|
|
||||||
ADD COLUMN `poster_url` VARCHAR(2048) NULL
|
|
||||||
COMMENT '영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)' AFTER `result_movie_url`;
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: video 테이블에 SNS 메타데이터 컬럼 추가
|
|
||||||
-- Date: 2026-08-19
|
|
||||||
-- Description: 영상 생성 완료 시 저장하는 제목/설명/해시태그.
|
|
||||||
-- 관련 코드: app/social/services/seo_service.py,
|
|
||||||
-- app/video/worker/video_task.py
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE `video`
|
|
||||||
ADD COLUMN `title` VARCHAR(100) NULL
|
|
||||||
COMMENT 'SNS 업로드 제목' AFTER `poster_url`,
|
|
||||||
ADD COLUMN `description` TEXT NULL
|
|
||||||
COMMENT 'SNS 업로드 설명' AFTER `title`,
|
|
||||||
ADD COLUMN `hashtags` JSON NULL
|
|
||||||
COMMENT 'SNS 해시태그 목록' AFTER `description`;
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
Loading…
Reference in New Issue
Block a user