최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
216 lines
11 KiB
Python
216 lines
11 KiB
Python
"""빌드 스냅샷 — ★ 사이트에 나가면 안 되는 것이 스냅샷에 들어오지 않는가.
|
|
|
|
스냅샷이 정적 빌드의 경계다. 여기 들어온 것은 그대로 발행되므로, 필터링이 여기서 새면 끝이다.
|
|
"""
|
|
import uuid
|
|
|
|
from sqlalchemy import text
|
|
|
|
from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType
|
|
from services.snapshot import build_snapshot
|
|
|
|
|
|
async def _seed(db_engine, company_id, category=PlaceCategory.LODGING):
|
|
pid = uuid.uuid4()
|
|
async with db_engine.begin() as c:
|
|
await c.execute(
|
|
text("INSERT INTO places (place_id, company_id, name, category, status, road_address, phone, verified_at) "
|
|
"VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"),
|
|
{"p": pid, "c": uuid.UUID(company_id), "n": "스냅샷펜션", "cat": category.value,
|
|
"addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"},
|
|
)
|
|
return pid
|
|
|
|
|
|
async def _fact(db_engine, pid, key, value, status, unit_id=None):
|
|
async with db_engine.begin() as c:
|
|
await c.execute(
|
|
text("INSERT INTO facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) "
|
|
"VALUES (:f,:p,:u,:k,:v,:s,:st,now())"),
|
|
{"f": uuid.uuid4(), "p": pid, "u": unit_id, "k": key, "v": value,
|
|
"s": SourceType.OWNER.value, "st": status.value},
|
|
)
|
|
|
|
|
|
async def _media(db_engine, pid, url, status, alt="설명"):
|
|
async with db_engine.begin() as c:
|
|
await c.execute(
|
|
text("INSERT INTO media (media_id, place_id, url, origin_url, source_type, status, alt_text, sort_order) "
|
|
"VALUES (:m,:p,:u,:u,:s,:st,:a,0)"),
|
|
{"m": uuid.uuid4(), "p": pid, "u": url, "s": SourceType.CRAWL.value, "st": status.value, "a": alt},
|
|
)
|
|
|
|
|
|
class _Place:
|
|
def __init__(self, pid, category=PlaceCategory.LODGING):
|
|
self.place_id = pid
|
|
self.category = category.value
|
|
self.name = "스냅샷펜션"
|
|
self.road_address = "강원특별자치도 양양군 현북면 하조대3길 11"
|
|
self.address = None
|
|
self.phone = "033-000-0000"
|
|
self.latitude = None
|
|
self.longitude = None
|
|
|
|
|
|
async def test_only_publishable_facts_enter_snapshot(db_engine, company_id):
|
|
"""검증: 여러 상태의 fact 를 섞어 넣는다.
|
|
기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다."""
|
|
pid = await _seed(db_engine, company_id)
|
|
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
|
|
await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED)
|
|
await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED)
|
|
await _fact(db_engine, pid, "breakfast", "true", FactStatus.PENDING_OWNER)
|
|
await _fact(db_engine, pid, "bbq_available", "true", FactStatus.REJECTED)
|
|
await _fact(db_engine, pid, "smoking", "false", FactStatus.EXPIRED)
|
|
|
|
snap = await build_snapshot(_Place(pid))
|
|
keys = {f["key"] for f in snap["facts"]}
|
|
assert keys == {"check_in_time", "wifi"}
|
|
|
|
|
|
async def test_only_approved_media_enters_snapshot(db_engine, company_id):
|
|
"""검증: 승인/확인대기/반려 사진을 섞어 넣는다.
|
|
기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다."""
|
|
pid = await _seed(db_engine, company_id)
|
|
await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED)
|
|
await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW)
|
|
await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED)
|
|
|
|
snap = await build_snapshot(_Place(pid))
|
|
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"]
|
|
|
|
|
|
async def test_media_without_alt_is_excluded(db_engine, company_id):
|
|
"""검증: 승인됐지만 alt 텍스트가 없는 사진.
|
|
기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다."""
|
|
pid = await _seed(db_engine, company_id)
|
|
await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="")
|
|
await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진")
|
|
|
|
snap = await build_snapshot(_Place(pid))
|
|
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"]
|
|
|
|
|
|
async def test_fact_labels_come_from_category_schema(db_engine, company_id):
|
|
"""검증: 스냅샷의 fact 라벨.
|
|
기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게."""
|
|
pid = await _seed(db_engine, company_id)
|
|
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
|
|
|
|
snap = await build_snapshot(_Place(pid))
|
|
f = snap["facts"][0]
|
|
assert f["label"] == "체크인 시간"
|
|
assert f["scope"] == "place"
|
|
|
|
|
|
async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id):
|
|
"""검증: 객실 단위 fact.
|
|
기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다."""
|
|
pid = await _seed(db_engine, company_id)
|
|
uid = uuid.uuid4()
|
|
async with db_engine.begin() as c:
|
|
await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"),
|
|
{"u": uid, "p": pid, "n": "A동"})
|
|
await _fact(db_engine, pid, "max_capacity", "4", FactStatus.VERIFIED, unit_id=uid)
|
|
|
|
snap = await build_snapshot(_Place(pid))
|
|
assert snap["units"][0]["name"] == "A동"
|
|
assert snap["facts"][0]["unit_id"] == str(uid)
|
|
assert snap["facts"][0]["scope"] == "unit"
|
|
|
|
|
|
async def test_empty_place_gives_empty_snapshot(db_engine, company_id):
|
|
"""검증: 아무것도 없는 사업장.
|
|
기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다."""
|
|
pid = await _seed(db_engine, company_id)
|
|
snap = await build_snapshot(_Place(pid))
|
|
assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == []
|
|
assert snap["place"]["name"] == "스냅샷펜션"
|
|
|
|
|
|
# ── 지역 정보 ─────────────────────────────────────────────────────────────
|
|
# ★ 지역 정보가 스냅샷에 담기는 이유: site_payload 는 DB 를 다시 읽지 않는다.
|
|
# 거기서 지역 캐시를 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 '스냅샷과 다른 페이지'가 나온다.
|
|
|
|
async def _local(db_engine, region_code, content_type, status, title="지역행사", **cols):
|
|
from common.enums import LocalSource
|
|
|
|
async with db_engine.begin() as c:
|
|
await c.execute(
|
|
text("INSERT INTO local_contents "
|
|
"(local_content_id, region_code, content_type, source, external_id, title, body, status, "
|
|
" display_start_at, display_end_at, collected_at) "
|
|
"VALUES (:i,:r,:ct,:src,:ext,:t,cast(:b as jsonb),:st,:ds,:de,now())"),
|
|
{"i": uuid.uuid4(), "r": region_code, "ct": content_type.value, "src": LocalSource.TOUR_API.value,
|
|
"ext": uuid.uuid4().hex, "t": title, "b": '{"title":"%s"}' % title, "st": status.value,
|
|
"ds": cols.get("display_start_at"), "de": cols.get("display_end_at")},
|
|
)
|
|
|
|
|
|
class _RegionPlace(_Place):
|
|
"""region_code 를 가진 사업장. 지역 캐시의 키는 place_id 가 아니라 region_code 다."""
|
|
|
|
def __init__(self, pid, region_code):
|
|
super().__init__(pid)
|
|
self.region_code = region_code
|
|
|
|
|
|
async def test_only_published_local_content_enters_snapshot(db_engine, company_id):
|
|
"""검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.
|
|
기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
|
|
'미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙)."""
|
|
from common.enums import LocalContentStatus, LocalContentType
|
|
|
|
pid = await _seed(db_engine, company_id)
|
|
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제")
|
|
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제")
|
|
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제")
|
|
|
|
snap = await build_snapshot(_RegionPlace(pid, "4113500"))
|
|
assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"]
|
|
|
|
|
|
async def test_local_content_outside_display_window_is_excluded(db_engine, company_id):
|
|
"""검증: 발행됐지만 노출 기간을 벗어난 지역 정보.
|
|
기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from common.enums import LocalContentStatus, LocalContentType
|
|
|
|
now = datetime.now(timezone.utc)
|
|
pid = await _seed(db_engine, company_id)
|
|
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제")
|
|
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제",
|
|
display_end_at=now - timedelta(days=1))
|
|
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "아직축제",
|
|
display_start_at=now + timedelta(days=1))
|
|
|
|
snap = await build_snapshot(_RegionPlace(pid, "4113500"))
|
|
assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"]
|
|
|
|
|
|
async def test_local_content_is_scoped_to_the_places_region(db_engine, company_id):
|
|
"""검증: 지역 캐시는 region_code 로 묶인다.
|
|
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
|
|
from common.enums import LocalContentStatus, LocalContentType
|
|
|
|
pid = await _seed(db_engine, company_id)
|
|
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제")
|
|
await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제")
|
|
|
|
snap = await build_snapshot(_RegionPlace(pid, "4113500"))
|
|
assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"]
|
|
|
|
|
|
async def test_place_without_region_code_gets_no_local_content(db_engine, company_id):
|
|
"""검증: region_code 가 비어 있는 사업장(수집이 지역을 특정하지 못한 경우).
|
|
기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다."""
|
|
from common.enums import LocalContentStatus, LocalContentType
|
|
|
|
pid = await _seed(db_engine, company_id)
|
|
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제")
|
|
|
|
snap = await build_snapshot(_RegionPlace(pid, ""))
|
|
assert snap["local"] == {"region_code": None, "contents": []}
|