Compare commits

..

No commits in common. "4cd756108d96dcd857414f9219e42d660f2f8e86" and "820a2e9e357512b3f0ff4d97b4cb985b1fc57dbb" have entirely different histories.

12 changed files with 30 additions and 140 deletions

View File

@ -5,34 +5,12 @@ 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 place_photos, places, site_publish_logs, site_versions, sites
from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus
from common.database.model.models import places, site_publish_logs, site_versions, sites
from common.enums import BuildStatus, ErrorType, 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
@ -45,7 +23,6 @@ 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
@ -90,7 +67,6 @@ class ISiteCRUD(ABC):
@abstractmethod
async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]:
"""(ErrorType, [(site, place, primary_photo_url)])."""
pass
@ -122,16 +98,10 @@ 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, primary_photo_url)], 총건수).
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 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
@ -141,7 +111,7 @@ class SiteCRUD(ISiteCRUD):
total = int(cnt_rows[0] or 0) if cnt_rows else 0
query = (
select(places, sites, site_versions.built_at, _primary_photo_subquery())
select(places, sites, site_versions.built_at)
.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)
@ -295,18 +265,14 @@ 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, _primary_photo_subquery())
select(sites, places)
.join(places, places.place_id == sites.place_id)
.where(
sites.status == SiteStatus.PUBLISHED.value,

View File

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

View File

@ -460,24 +460,14 @@ class SiteService:
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.sites = [
self._my_site_row(place, site, built_at, primary_photo_url)
for place, site, built_at, primary_photo_url in rows
]
res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows]
res.total = total
return res
@staticmethod
def _my_site_row(place, site, built_at, primary_photo_url) -> MySiteData:
def _my_site_row(place, site, built_at) -> 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,
@ -490,7 +480,7 @@ class SiteService:
domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", None),
thumbnail_url=thumbnail_url,
thumbnail_url=getattr(site, "thumbnail_url", None),
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
)

View File

@ -8,9 +8,6 @@ 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줄 · "검색으로 열기".
*
@ -18,21 +15,11 @@ export const naverMapSearch = (q: string) => `https://map.naver.com/p/search/${e
* 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다.
* ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다.
*/
function GuideCardView({
card,
colors,
leading,
linkUrl = naverSearch,
}: {
card: GuideCard;
colors: TemplateItem['colors'];
leading?: ReactNode;
linkUrl?: (query: string) => string;
}) {
function GuideCardView({card, colors, leading}: {card: GuideCard; colors: TemplateItem['colors']; leading?: ReactNode}) {
const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined;
return (
<a
href={linkUrl(card.searchQuery)}
href={naverSearch(card.searchQuery)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
@ -87,14 +74,11 @@ 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);
@ -187,7 +171,6 @@ 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, naverMapSearch} from './PlaceCarousel';
export {PlaceCarousel} from './PlaceCarousel';
export {WalkFilterTabs} from './WalkFilterTabs';

View File

@ -18,7 +18,6 @@ export function LocalCategorySection({
emptyText,
colors,
renderLeading,
linkUrl,
}: {
icon: LucideIcon;
title: string;
@ -26,7 +25,6 @@ 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));
@ -54,7 +52,7 @@ export function LocalCategorySection({
{visible.length === 0 ? (
<EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice>
) : (
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} linkUrl={linkUrl} />
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} />
)}
</>
)}

View File

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

View File

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