Compare commits

..

3 Commits

Author SHA1 Message Date
4cd756108d Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-15 17:21:40 +09:00
217d0853bc [fix] solution: 썸네일 저장소 없이도 내 사이트·랜딩 카드에 그림을 채운다
Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발 등) sites.thumbnail_url 이
계속 비어 있어 발행된 사이트도 카드가 아이콘으로 떨어졌다. 그 대신 빌더가
이미 쓰는 대표 사진(place_photos)을 한 번이라도 발행한 줄에 채운다 —
내 사이트 목록과 랜딩 쇼케이스 둘 다 같은 규칙.

EssentialInfoSection 예약 공지 라벨에서 채널명(NOL)을 뺀다.
2026-09-15 17:19:07 +09:00
a6ddeccdff [feat] solution: 맛집 카드 사진 필터링·지도검색 연결
사진(imageUrl) 없는 맛집은 카드 목록에서 제외한다(명소는 이름 대체 규칙 유지).
맛집·명소 카드의 "검색으로 열기"는 네이버 웹검색 대신 네이버 지도검색으로 연결한다.
빌더 관리자 미리보기(LocalGuide)도 실제 사이트와 동일하게 맞춘다(축제 카드는 기존 웹검색 유지).

검증: site 81건(신규 회귀 3건 포함) 통과. site·frontend 모두 tsc --noEmit 통과.
2026-09-15 16:28:39 +09:00
12 changed files with 140 additions and 30 deletions

View File

@ -5,12 +5,34 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places, site_publish_logs, site_versions, sites
from common.enums import BuildStatus, ErrorType, SiteStatus
from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites
from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus
from common.logger import LOG
from common.utils.gtime import GTime
def _primary_photo_subquery():
"""place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행).
site_payload.primary_media 와 같은 규칙 — 객실·메뉴 사진(unit_id 있음)이 아닌 첫 장,
sort_order 순. `.correlate(places)` 라서 바깥 쿼리가 `places` 를 셀렉트에 들고 있어야 한다.
sites.thumbnail_url 이 비어 있을 때(Azure 썸네일 저장소 미설정 등) 서비스 계층이 이걸로
대신 채운다 — 여기서는 후보만 얹고, 언제 쓸지는 서비스 계층 몫이다."""
return (
select(place_photos.url)
.where(
place_photos.place_id == places.place_id,
place_photos.deleted == False, # noqa: E712
place_photos.status == MediaStatus.APPROVED.value,
place_photos.unit_id.is_(None),
)
.order_by(place_photos.sort_order.asc(), place_photos.created_at.asc())
.limit(1)
.correlate(places)
.scalar_subquery()
)
# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다.
class ISiteCRUD(ABC):
@abstractmethod
@ -23,6 +45,7 @@ class ISiteCRUD(ABC):
@abstractmethod
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]:
"""(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수)."""
pass
@abstractmethod
@ -67,6 +90,7 @@ class ISiteCRUD(ABC):
@abstractmethod
async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]:
"""(ErrorType, [(site, place, primary_photo_url)])."""
pass
@ -98,10 +122,16 @@ class SiteCRUD(ISiteCRUD):
return ErrorType.DB_RUN_FAILED, None
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진.
(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.
★ primary_photo_url 은 site_payload.primary_media 와 같은 규칙(사진 중 객실·메뉴가 아닌
첫 장, sort_order 순)으로 고른 place_photos.url 이다 — sites.thumbnail_url 이 비어 있을 때
(Azure 썸네일 저장소 미설정 등으로 재호스팅에 실패한 경우) 서비스 계층이 이걸로 대신 채운다.
여기서는 후보만 얹고, "발행한 적 있는 줄에만 쓴다"는 판단은 서비스 계층 몫이다."""
try:
where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
@ -111,7 +141,7 @@ class SiteCRUD(ISiteCRUD):
total = int(cnt_rows[0] or 0) if cnt_rows else 0
query = (
select(places, sites, site_versions.built_at)
select(places, sites, site_versions.built_at, _primary_photo_subquery())
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
.where(where)
@ -265,14 +295,18 @@ class SiteCRUD(ISiteCRUD):
return ErrorType.DB_RUN_FAILED, []
async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]:
"""발행된 사이트 + 그 사업장을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다.
"""발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다.
(ErrorType, [(site, place, primary_photo_url)]).
★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로
돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 —
여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다."""
여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다.
★ primary_photo_url 은 list_owner_sites 와 같은 서브쿼리(_primary_photo_subquery) —
sites.thumbnail_url 이 비어 있을 때 showcase_service 가 이걸로 대신 채운다."""
try:
query = (
select(sites, places)
select(sites, places, _primary_photo_subquery())
.join(places, places.place_id == sites.place_id)
.where(
sites.status == SiteStatus.PUBLISHED.value,

View File

@ -97,6 +97,8 @@ class MySiteData(WebPacketProtocol):
template_id: Optional[str] = None
published_at: Optional[datetime] = None
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
# Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발) 빌더가 쓰는 대표 사진으로 대신 채운다
# (site_service._my_site_row) — 이때는 `?v=` 가 없다.
thumbnail_url: Optional[str] = None
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
needs_rebuild: bool = False

View File

@ -37,8 +37,11 @@ class ShowcaseService:
# ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면
# 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳').
url=f"/s/{site_payload.publish_slug(place, site)}",
thumbnail_url=site.thumbnail_url,
# ★ sites.thumbnail_url 은 Azure 썸네일 저장소가 꺼져 있으면(로컬 개발 등) 비어
# 있다 — 이 목록은 이미 PUBLISHED 만 걷었으므로, 그때는 빌더가 쓰는 대표 사진
# (primary_photo_url)으로 대신 채운다(services/site_service._my_site_row 와 같은 규칙).
thumbnail_url=site.thumbnail_url or primary_photo_url,
)
for site, place in rows
for site, place, primary_photo_url in rows
]
return res

View File

@ -460,14 +460,24 @@ class SiteService:
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows]
res.sites = [
self._my_site_row(place, site, built_at, primary_photo_url)
for place, site, built_at, primary_photo_url in rows
]
res.total = total
return res
@staticmethod
def _my_site_row(place, site, built_at) -> MySiteData:
def _my_site_row(place, site, built_at, primary_photo_url) -> MySiteData:
# ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
changed = place.content_updated_at
# ★ sites.thumbnail_url 은 발행할 때 Azure 에 대표 사진을 재호스팅해야 채워진다
# (services/site_thumbnail.store) — 저장소가 안 꺼져 있으면(로컬 개발 등) 늘 비어 있다.
# 그래도 "한 번이라도 발행한 줄은 그림"이라는 화면 규칙은 지켜야 하므로, 빌더가 이미
# 쓰고 있는 대표 사진(place_photos, primary_photo_url)으로 대신 채운다 — 발행 전 줄에는
# 쓰지 않는다(그 규칙은 published_at 유무로 가른다: crud.site_crud.list_owner_sites).
ever_published = site is not None and getattr(site, "published_at", None) is not None
thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None)
return MySiteData(
place_id=place.place_id,
name=place.name,
@ -480,7 +490,7 @@ class SiteService:
domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", None),
thumbnail_url=getattr(site, "thumbnail_url", None),
thumbnail_url=thumbnail_url,
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
)

View File

@ -8,6 +8,9 @@ import {walkMinutes} from '../variants/local/walking';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
/** 네이버지도 검색 — 맛집·명소 카드가 쓴다(실제 사이트 LocalGuideSection과 동일한 규칙). */
export const naverMapSearch = (q: string) => `https://map.naver.com/p/search/${encodeURIComponent(q)}`;
/**
* 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기".
*
@ -15,11 +18,21 @@ const naverSearch = (q: string) =>
* 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다.
* ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다.
*/
function GuideCardView({card, colors, leading}: {card: GuideCard; colors: TemplateItem['colors']; leading?: ReactNode}) {
function GuideCardView({
card,
colors,
leading,
linkUrl = naverSearch,
}: {
card: GuideCard;
colors: TemplateItem['colors'];
leading?: ReactNode;
linkUrl?: (query: string) => string;
}) {
const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined;
return (
<a
href={naverSearch(card.searchQuery)}
href={linkUrl(card.searchQuery)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
@ -74,11 +87,14 @@ export function PlaceCarousel({
cards,
colors,
renderLeading,
linkUrl,
}: {
cards: GuideCard[];
colors: TemplateItem['colors'];
/** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */
renderLeading?: (card: GuideCard) => ReactNode;
/** 카드가 열 링크. 기본은 네이버 웹 검색이고, 맛집·명소는 네이버지도 검색(naverMapSearch)을 넘긴다. */
linkUrl?: (query: string) => string;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [perPage, setPerPage] = useState(1);
@ -171,6 +187,7 @@ export function PlaceCarousel({
card={card}
colors={colors}
leading={renderLeading?.(card)}
linkUrl={linkUrl}
/>
))}
</div>

View File

@ -19,5 +19,5 @@ export {Pill} from './Pill';
export {CtaLink, type CtaStyle} from './CtaLink';
export {Rail} from './Rail';
/* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */
export {PlaceCarousel} from './PlaceCarousel';
export {PlaceCarousel, naverMapSearch} from './PlaceCarousel';
export {WalkFilterTabs} from './WalkFilterTabs';

View File

@ -18,6 +18,7 @@ export function LocalCategorySection({
emptyText,
colors,
renderLeading,
linkUrl,
}: {
icon: LucideIcon;
title: string;
@ -25,6 +26,7 @@ export function LocalCategorySection({
emptyText: string;
colors: TemplateItem['colors'];
renderLeading?: (card: GuideCard) => ReactNode;
linkUrl?: (query: string) => string;
}) {
const [filter, setFilter] = useState<WalkFilterKey>('all');
const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters));
@ -52,7 +54,7 @@ export function LocalCategorySection({
{visible.length === 0 ? (
<EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice>
) : (
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} />
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} linkUrl={linkUrl} />
)}
</>
)}

View File

@ -9,7 +9,7 @@
*/
import {Calendar, MapPin, Utensils} from 'lucide-react';
import {useLocalGuide} from '@/hooks/useLocalGuide';
import {Pill, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import {Pill, SectionBody, SectionFrame, SectionHeading, naverMapSearch} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import type {FestivalCard, GuideCard} from './types';
import {LocalCategorySection} from './LocalCategorySection';
@ -46,12 +46,16 @@ export function LocalGuide(props: SectionRenderProps) {
colors={colors}
/>
{/* ★ 맛집은 사진이 없으면 목록에서 뺀다 (2026-09-15, 사장님 지시) — 명소는 이름 활자로 대신하는
규칙을 유지하지만, 맛집은 실제 사이트(LocalGuideSection)와 맞춰 사진 없는 곳을 숨긴다. */}
{/* ★ 맛집·명소 카드는 네이버지도 검색으로 연다 (2026-09-15, 사장님 지시) — 실제 사이트와 동일. */}
<LocalCategorySection
icon={Utensils}
title="주변 맛집"
cards={foods}
cards={foods.filter((card) => card.imageUrl)}
emptyText="주변 맛집·카페 추천은 아직 준비 중입니다."
colors={colors}
linkUrl={naverMapSearch}
/>
<LocalCategorySection
@ -60,6 +64,7 @@ export function LocalGuide(props: SectionRenderProps) {
cards={spots}
emptyText="주변 명소 추천은 아직 준비 중입니다."
colors={colors}
linkUrl={naverMapSearch}
/>
{isStay && (

View File

@ -34,6 +34,16 @@ export function naverSearchUrl(query: string): string {
return `https://search.naver.com/search.naver?query=${encodeURIComponent(query)}`;
}
/**
* 네이버지도 검색으로 보내는 링크.
*
* ★ 주변 맛집·명소 카드의 "검색으로 열기"는 여기로 보낸다 (2026-09-15, 사장님 지시) —
* 손님이 카드를 누르는 목적이 "이 가게가 어디 있나"라, 웹 검색보다 지도가 바로 답이다.
*/
export function naverMapSearchUrl(query: string): string {
return `https://map.naver.com/p/search/${encodeURIComponent(query)}`;
}
/**
* 유튜브 **검색** 주소.
*

View File

@ -113,7 +113,7 @@ export function EssentialInfoSection() {
{otherRows.length > 0 && <Rows title="시설 · 편의" rows={otherRows} />}
{guides.filter((link) => link.stayGuide?.reservation).map((link) => (
<Rows key={`reservation-${link.url}`} title="예약 공지" emphasis rows={[
{label: 'NOL 예약 공지', value: link.stayGuide?.reservation ?? ''},
{label: '예약 공지', value: link.stayGuide?.reservation ?? ''},
]} />
))}
<BookingRow />

View File

@ -2,7 +2,7 @@ import {ArrowUpRight, MapPin, Utensils} from 'lucide-react';
import type {LocalPlace} from '@o2o/shared';
import {useState} from 'react';
import {useSite} from '@site/lib/site-context';
import {formatKoreanDate, naverSearchUrl} from '@site/lib/format';
import {formatKoreanDate, naverMapSearchUrl} from '@site/lib/format';
import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
/**
@ -72,11 +72,14 @@ export function LocalGuideSection() {
const {local} = payload;
// ★ 축제·행사는 여기서 그리지 않는다 (2026-09-03) — 날짜가 지나면 못 가는 것이라
// 아무 때나 가는 맛집·명소와 성격이 다르다. 계절을 축으로 세운 FestivalSection 이 맡는다.
const hasAnything = local.restaurants.length > 0 || local.attractions.length > 0;
// ★ 맛집은 사진이 없으면 아예 목록에서 뺀다 (2026-09-15, 사장님 지시) —
// 명소는 이름 활자로 대신하는 규칙을 유지하지만, 맛집은 사진 없는 곳을 보여주지 않는다.
const restaurants = local.restaurants.filter((place) => place.imageUrl);
const hasAnything = restaurants.length > 0 || local.attractions.length > 0;
const [range, setRange] = useState<string>('all');
if (!hasAnything) return null;
const all = [...local.restaurants, ...local.attractions];
const all = [...restaurants, ...local.attractions];
const count = (r: (typeof RANGES)[number]) =>
all.filter((place) => r.test(distanceOf(place))).length;
// 비었거나 전체와 똑같은 탭은 세우지 않는다 — 눌러도 그대로인 탭은 고장으로 읽힌다.
@ -126,8 +129,8 @@ export function LocalGuideSection() {
)}
<div className="space-y-10">
{local.restaurants.length > 0 && (
<PlaceList title="주변 맛집" icon={Utensils} places={local.restaurants} within={within} />
{restaurants.length > 0 && (
<PlaceList title="주변 맛집" icon={Utensils} places={restaurants} within={within} />
)}
{local.attractions.length > 0 && (
<PlaceList title="주변 명소" icon={MapPin} places={local.attractions} within={within} />
@ -181,7 +184,7 @@ function PlaceList({
{shown.map((place) => (
<CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
<a
href={naverSearchUrl(place.searchQuery)}
href={naverMapSearchUrl(place.searchQuery)}
target="_blank"
rel="noopener noreferrer nofollow"
className="panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85"
@ -251,7 +254,7 @@ function PlaceList({
.filter((place) => !within(place))
.map((place) => (
<li key={place.name}>
<a href={naverSearchUrl(place.searchQuery)} rel="nofollow">
<a href={naverMapSearchUrl(place.searchQuery)} rel="nofollow">
{place.name}
</a>
{place.distanceText && (

View File

@ -9,9 +9,9 @@ it('renders collected descriptions and addresses with numeric distance filters',
payload.local.attractions = [];
payload.local.restaurants = [
{name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', location: 'Address A',
description: 'Collected description', distanceMeters: 401, distanceText: '400m'},
{name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100},
{name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C'},
description: 'Collected description', distanceMeters: 401, distanceText: '400m', imageUrl: 'https://example.com/a.jpg'},
{name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100, imageUrl: 'https://example.com/b.jpg'},
{name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C', imageUrl: 'https://example.com/c.jpg'},
];
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
expect(html).toContain('Collected description');
@ -19,3 +19,27 @@ it('renders collected descriptions and addresses with numeric distance filters',
expect(html).toMatch(/걸어서 5분 이내 <span[^>]*>1<\/span>/);
expect(html).toContain('Cafe C');
});
it('excludes restaurants without an image', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.local.attractions = [];
payload.local.restaurants = [
{name: 'Cafe With Image', category: 'cafe', searchQuery: 'Cafe With Image', imageUrl: 'https://example.com/a.jpg'},
{name: 'Cafe Without Image', category: 'cafe', searchQuery: 'Cafe Without Image'},
];
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
expect(html).toContain('Cafe With Image');
expect(html).not.toContain('Cafe Without Image');
});
it('links restaurant and attraction cards to Naver Map search, not web search', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.local.restaurants = [
{name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', imageUrl: 'https://example.com/a.jpg'},
];
payload.local.attractions = [{name: 'Spot A', category: 'spot', searchQuery: 'Spot A'}];
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
expect(html).toContain('https://map.naver.com/p/search/Cafe%20A');
expect(html).toContain('https://map.naver.com/p/search/Spot%20A');
expect(html).not.toContain('search.naver.com');
});