[fix] solution: 썸네일 저장소 없이도 내 사이트·랜딩 카드에 그림을 채운다

Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발 등) sites.thumbnail_url 이
계속 비어 있어 발행된 사이트도 카드가 아이콘으로 떨어졌다. 그 대신 빌더가
이미 쓰는 대표 사진(place_photos)을 한 번이라도 발행한 줄에 채운다 —
내 사이트 목록과 랜딩 쇼케이스 둘 다 같은 규칙.

EssentialInfoSection 예약 공지 라벨에서 채널명(NOL)을 뺀다.
This commit is contained in:
김성경 2026-09-15 17:19:07 +09:00
parent a6ddeccdff
commit 217d0853bc
5 changed files with 63 additions and 14 deletions

View File

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

View File

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

View File

@ -37,8 +37,11 @@ class ShowcaseService:
# ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면 # ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면
# 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳'). # 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳').
url=f"/s/{site_payload.publish_slug(place, site)}", 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 return res

View File

@ -460,14 +460,24 @@ class SiteService:
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res 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 res.total = total
return res return res
@staticmethod @staticmethod
def _my_site_row(place, site, built_at) -> MySiteData: def _my_site_row(place, site, built_at, primary_photo_url) -> MySiteData:
# ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다. # ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
changed = place.content_updated_at 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( return MySiteData(
place_id=place.place_id, place_id=place.place_id,
name=place.name, name=place.name,
@ -480,7 +490,7 @@ class SiteService:
domain=getattr(site, "domain", None), domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None), template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", 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)), needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
) )

View File

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