From 61bdc27cd32e937b5566a6f295a4d8de2bf6df40 Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 21 Aug 2026 10:44:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(video):=20=EC=98=81=EC=83=81=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20official=5Fsite=5Furl=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /video/{id}와 GET /video/all 응답에 업체 공식 페이지 URL을 추가한다. Project.marketing_intelligence → MarketingIntel.place_id("nv{place_id}")를 경유해 네이버 플레이스 URL로 복원하며, 크롤링 없이 직접 입력된 업체는 null로 내려간다. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS --- app/video/api/routers/v1/video.py | 53 +++++++++++++++++++++++++++++++ app/video/schemas/video_schema.py | 8 +++++ 2 files changed, 61 insertions(+) diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 795f86f..9129552 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -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: diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index 2da6aba..d921607 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -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):