feat(video): 영상 응답에 official_site_url 필드 추가

GET /video/{id}와 GET /video/all 응답에 업체 공식 페이지 URL을 추가한다.
Project.marketing_intelligence → MarketingIntel.place_id("nv{place_id}")를
경유해 네이버 플레이스 URL로 복원하며, 크롤링 없이 직접 입력된 업체는
null로 내려간다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS
This commit is contained in:
hbyang 2026-08-21 10:44:44 +09:00
parent 9d40313fbb
commit 61bdc27cd3
2 changed files with 61 additions and 0 deletions

View File

@ -74,6 +74,51 @@ logger = get_logger("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) 경유해
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).where(
MarketingIntel.id.in_(set(m_id_by_project.values()))
)
)
).all()
place_id_by_m_id = {m_id: place_id for m_id, place_id in rows}
for project_id, m_id in m_id_by_project.items():
url_by_project[project_id] = _place_id_to_site_url(
place_id_by_m_id.get(m_id)
)
return url_by_project
@router.get(
"/generate/{task_id}",
@ -989,6 +1034,10 @@ async def get_all_videos(
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 = [
VideoThumbnailItem(
video_id=v.id,
@ -999,6 +1048,7 @@ async def get_all_videos(
like_count=like_count_map.get(v.id) or 0,
is_liked_by_me=liked_map.get(v.id, False),
comment_count=comment_count or 0,
official_site_url=official_site_url_map.get(p.id),
)
for v, p, comment_count in rows
]
@ -1214,6 +1264,8 @@ async def get_video_detail(
liked = False
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}")
return VideoDetailResponse(
video_id=video.id,
@ -1226,6 +1278,7 @@ async def get_video_detail(
created_at=video.created_at,
like_count=like_count,
is_liked_by_me=is_liked_by_me,
official_site_url=official_site_url_map.get(project.id),
)
except HTTPException:

View File

@ -186,6 +186,10 @@ class VideoThumbnailItem(BaseModel):
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
official_site_url: Optional[str] = Field(
None,
description="업체 공식 페이지 URL (크롤링된 네이버 플레이스 기준, 없으면 null)",
)
class VideoDetailResponse(BaseModel):
@ -205,6 +209,10 @@ class VideoDetailResponse(BaseModel):
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
official_site_url: Optional[str] = Field(
None,
description="업체 공식 페이지 URL (크롤링된 네이버 플레이스 기준, 없으면 null)",
)
class LikeToggleResponse(BaseModel):