From 8410380769c4907875f064b954100bf8f9304d6f Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 12:00:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[fix]=20solution/backend,shared,site:=20?= =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=EC=97=90=20=EC=93=B4=20=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EB=AC=B8=EC=9D=B4=20=EB=B0=9C=ED=96=89=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8D=98=20=EA=B5=AC?= =?UTF-8?q?=EB=A9=8D=20=E2=80=94=20=EA=B3=84=EC=95=BD=EC=97=90=20body=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사장님이 소개 섹션에 본문을 써도 발행이 "고유 콘텐츠 0건"으로 거부됐다. 본문은 sites.theme 에 저장은 되는데 payload 경계에서 버려졌다 — _sections() 가 저장값에서 id·name·enabled·locked·variantId 다섯 개만 꺼내 새로 만들었다. 그래서 발행본에 안 나오고, 계수에도 안 잡혔다. 거부 문구도 틀렸다. 렌더러가 '고유 콘텐츠 0건'을 JSON-LD 불일치와 같은 VerifyError 의 mismatches 에 실어 던져서, 백엔드가 JSONLD_MISMATCH 로 판정하고 화면에는 "구조화 데이터와 화면 값이 다릅니다" 가 떴다. 구조화 데이터는 멀쩡했다. - shared/site-payload: SectionSetting.body 추가 — variantId 와 같은 사연 - backend/site_payload: 저장된 body 를 payload 까지 실어 보낸다 - site/derive,AboutSection: 직접 쓴 본문을 그린다. 없으면 intro fact 로 떨어진다 - site/prerender: 켜진 소개 섹션의 8자 이상 본문을 고유 콘텐츠로 계수 - site/prerender: NoUniqueContentError 분리 — mismatches 를 비워 라벨이 안 섞이게. 계수를 못 잰 실패는 null 로 보고한다(0 으로 적으면 디스크 오류가 같은 사유를 받는다) - backend/build_service,publish_gate: 렌더 실패가 0건이면 NO_UNIQUE_CONTENT 라벨을 붙인다. evaluate() 는 안 건드렸다 — 얇은 콘텐츠로 발행을 막지 않기로 한 결정 그대로다 - backend/router: theme API 설명에 body 반영 테스트 8 failed / 511 passed. 실패 8건은 변경 전(508 passed)과 동일한 기존 실패다 (test_default_sections_match_the_editor 의 solution/front 경로 오타 등). tsc·site·shared 통과. 실물 검증: 본문만 있는 payload → ok=true, uniqueContentCount=1, 발행 HTML 에 문장 포함. 같은 payload 에서 본문을 빼면 0건으로 거부. --- docs/DEVLOG.md | 13 ++++++++ solution/backend/conftest.py | 6 ++++ solution/backend/router/v1/site/protocol.py | 4 +-- solution/backend/router/v1/site/site.py | 2 +- solution/backend/services/build_service.py | 20 +++++++++-- solution/backend/services/publish_gate.py | 23 ++++++++++--- solution/backend/services/site_payload.py | 9 +++++ solution/backend/tests/test_build_publish.py | 23 +++++++++++++ solution/backend/tests/test_publish_gate.py | 6 ++++ solution/backend/tests/test_site_theme.py | 12 +++++++ solution/shared/src/types/site-payload.ts | 9 +++++ solution/site/scripts/prerender.ts | 35 ++++++++++++++++---- solution/site/src/lib/derive.ts | 6 ++++ solution/site/src/sections/AboutSection.tsx | 10 ++++-- 14 files changed, 159 insertions(+), 19 deletions(-) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index e017d1f..1222ff6 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,19 @@ --- +## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 + +**왜** +에디터의 소개 섹션 본문은 `sites.theme` 에 저장됐지만 발행 payload 경계에서 버려졌고, +프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다. + +**한 일** +- `SectionSetting.body` 계약을 추가하고 저장값을 payload 까지 전달 +- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수 +- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리 + +**검증** — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가. + ## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개) **왜** diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index 1c5767b..4fb6a94 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -201,6 +201,12 @@ def fake_renderer(monkeypatch, tmp_path_factory): return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT count = 0 + intro = next( + (s for s in (payload.get("theme") or {}).get("sections") or [] if s.get("id") == "intro"), + None, + ) + if intro and intro.get("enabled") and long(intro.get("body")): + count += 1 facts = list(payload.get("facts") or []) for unit in payload.get("units") or []: facts.extend(unit.get("facts") or []) diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 6750ec6..b8de4fd 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -88,8 +88,8 @@ class Req_SiteTheme(SiteProtocol): ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: {"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} - sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 - (별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. + sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 + (별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다. pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — 서버는 배달부지 심판이 아니다. diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py index 90c6bb3..f0f8d3a 100644 --- a/solution/backend/router/v1/site/site.py +++ b/solution/backend/router/v1/site/site.py @@ -106,7 +106,7 @@ async def set_template( description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다" "(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: " "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\"," - "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\"}]}}. " + "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. " "★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). " "★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. " "직렬화 크기(64KB)만 막는다. " diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index b72ecef..5bfbb93 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -200,7 +200,10 @@ async def run_build(job: dict) -> dict: ) mismatches = list(report.get("mismatches") or []) - unique_count = report.get("uniqueContentCount") or 0 + # ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고, + # 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다. + unique_count_raw = report.get("uniqueContentCount") + unique_count = unique_count_raw or 0 jsonld = report.get("jsonld") or [] result["unique_content_count"] = unique_count result["mismatches"] = mismatches[:20] @@ -211,14 +214,25 @@ async def run_build(job: dict) -> dict: # 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다. # 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 — # 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다. - gate = publish_gate.evaluate(PlaceCategory(place.category), snapshot["facts"], unique_count, mismatches) + gate = publish_gate.evaluate( + PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches + ) result["gate"] = {"passed": gate.passed, **gate.as_log()} if not gate.passed: return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp) if not report.get("ok"): - # 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있는 쪽이다. + # ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지 + # 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패" + # 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다. + thin = publish_gate.check_unique_content(unique_count_raw) + if not thin.passed: + # 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로 + # 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다. + result["gate"] = {"passed": False, **thin.as_log()} + return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp) + # 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있다. return await _fail(str(report.get("error") or "렌더 실패"), None, stamp) # DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다. diff --git a/solution/backend/services/publish_gate.py b/solution/backend/services/publish_gate.py index 78b50a0..6ff9ef7 100644 --- a/solution/backend/services/publish_gate.py +++ b/solution/backend/services/publish_gate.py @@ -63,11 +63,20 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult: return GateResult(True) -def check_unique_content(unique_content_count: int) -> GateResult: - """고유 콘텐츠 품질 진단용. 발행 게이트에서는 더 이상 호출하지 않는다. +def check_unique_content(unique_content_count: int | None) -> GateResult: + """렌더러가 '고유 콘텐츠 0건' 으로 거부했을 때 사유 코드를 붙이는 자리. + + evaluate() 는 이걸 부르지 않는다(얇은 콘텐츠로 발행을 막지 않기로 했다). + 부르는 곳은 build_service — 렌더 보고서가 실패로 왔을 때 그 이유를 되짚는다. 이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면 - 같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.""" + 같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다. + + ★ None 은 '0건' 이 아니라 '재지 못했다' 다. 렌더러가 디스크·번들 문제로 죽으면 계수가 + 없는 채로 보고서가 온다 — 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는 + 엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 가 말한다.""" + if unique_content_count is None: + return GateResult(True) if unique_content_count <= 0: return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count}) return GateResult(True) @@ -83,11 +92,17 @@ def check_jsonld_matches(mismatches: list) -> GateResult: return GateResult(True) -def evaluate(category: PlaceCategory, facts: list, unique_content_count: int, mismatches: list) -> GateResult: +def evaluate( + category: PlaceCategory, facts: list, unique_content_count: int | None, mismatches: list +) -> GateResult: """게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다. 순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓). + ★ unique_content_count 는 받되 여기서 막지 않는다 — 얇은 콘텐츠는 '틀린 것' 이 아니다 + (test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은 + 렌더러이고, 그 사유는 build_service 가 check_unique_content 로 되짚어 라벨을 붙인다. + ★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정). 막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다. 영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고, diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 150513b..e81fed6 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -363,6 +363,15 @@ def _sections(saved_sections, default_spec) -> list: variant_id = _text(item.get("variantId")) if variant_id: entry["variantId"] = variant_id + # ★ 사장님이 에디터에 직접 쓴 섹션 본문. variantId 와 같은 이유로 그대로 싣는다 — + # 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 통째로 버려졌다. + # 저장(sites.theme)은 되는데 발행본에는 안 나오고, 고유 콘텐츠로도 세지 않아 + # "소개를 썼는데 발행이 고유 콘텐츠 0건으로 막힌다" 가 됐다. + # ★ fact 가 아니라 검증 대상이 아니다. 사장님이 자기 가게에 대해 쓴 자기 문장이고, + # 섹션 제목(name)이 이미 같은 경로로 나간다. + body = _text(item.get("body")) + if body: + entry["body"] = body out.append(entry) for sid, label, locked in default_spec: diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index c8e6207..9aab5b2 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -136,6 +136,29 @@ async def test_no_unique_content_blocks_publish(auth_headers, client): assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name +async def test_owner_written_intro_counts_as_unique_content(auth_headers, client): + """검증: fact·FAQ·사진 설명은 없지만 사장님이 소개 섹션 본문을 직접 썼다. + 기대결과: 실제 발행본에 표시되는 소개문 1건으로 계수되어 발행된다.""" + h = await auth_headers("owner-intro") + pid = await _place(client, h, "직접소개펜션") + for k, v in {**REQUIRED, "cancel_policy": "X"}.items(): + await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v}) + await client.post(f"/v1/place/{pid}/site/theme", headers=h, json={"theme": {"sections": [{ + "id": "intro", "name": "소개", "enabled": True, "locked": False, + "body": "하조대의 아침 바다를 조용히 바라볼 수 있는 작은 숙소입니다.", + }]}}) + + job_id = (await client.post( + f"/v1/place/{pid}/site/build", headers=h, json={"publish": True} + )).json()["job_id"] + await _run() + + r = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]["result"] + assert r["gate"]["passed"] is True + assert r["unique_content_count"] == 1 + assert r["published"] is True + + async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine): """검증: 취소 규정 없이 빌드한다. diff --git a/solution/backend/tests/test_publish_gate.py b/solution/backend/tests/test_publish_gate.py index ac01c81..45c5fb8 100644 --- a/solution/backend/tests/test_publish_gate.py +++ b/solution/backend/tests/test_publish_gate.py @@ -79,6 +79,12 @@ def test_zero_unique_content_blocks_publish(): assert check_unique_content(1).passed is True +def test_unmeasured_unique_content_is_not_zero(): + """검증: 렌더러가 계수를 못 재고(None) 실패했다. + 기대결과: 통과 — 디스크·번들 오류에 NO_UNIQUE_CONTENT 라는 엉뚱한 사유가 붙으면 안 된다.""" + assert check_unique_content(None).passed is True + + def test_jsonld_mismatch_blocks_publish(): """검증: 구조화 데이터 값이 화면 값과 다르다. 기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다.""" diff --git a/solution/backend/tests/test_site_theme.py b/solution/backend/tests/test_site_theme.py index 4cbf587..1ec6f96 100644 --- a/solution/backend/tests/test_site_theme.py +++ b/solution/backend/tests/test_site_theme.py @@ -222,6 +222,18 @@ def test_owner_disabled_section_stays_disabled(): assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False +def test_owner_written_section_body_reaches_publish_payload(): + """검증: 에디터에서 직접 쓴 소개 본문. + 기대결과: 발행 payload 에 그대로 남는다 — 저장만 되고 경계에서 버려지면 발행본과 계수에 못 쓴다.""" + theme = _payload_theme({"sections": [{ + "id": "intro", "name": "소개", "enabled": True, "locked": False, + "body": "바다를 보며 조용히 쉬어가는 작은 숙소입니다.", + }]}) + assert next(s for s in theme["sections"] if s["id"] == "intro")["body"] == ( + "바다를 보며 조용히 쉬어가는 작은 숙소입니다." + ) + + def test_partial_colors_are_filled_from_category_default(): """검증: 저장된 색이 일부 키만 담고 있을 때. 기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로 diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 294be2f..eb0670e 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -249,4 +249,13 @@ export interface SectionSetting { * 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다. */ variantId?: string; + /** + * 사장님이 에디터에서 직접 쓴 섹션 본문. + * + * ★ variantId 와 같은 사연이다 — 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 + * 버려졌다. 저장(sites.theme)은 되는데 발행본에 안 나왔고, 고유 콘텐츠로도 세지 않아 + * "소개를 썼는데 고유 콘텐츠 0건으로 발행이 막힌다" 가 됐다. + * ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로

를 나눈다. + */ + body?: string; } diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 27da96d..1b79dd5 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -192,6 +192,24 @@ class VerifyError extends Error { } } +/** + * 고유 콘텐츠 0건. **VerifyError 와 갈라 둔다.** + * + * ★ 예전에는 이 사유를 VerifyError 의 mismatches 에 실어 던졌다. 백엔드 게이트는 + * mismatches 가 비지 않았다는 것만 보고 JSONLD_MISMATCH 로 판정했고, 사장님 화면에는 + * "구조화 데이터와 화면 값이 다릅니다" 라는 **틀린 문구**가 떴다 — 구조화 데이터는 + * 멀쩡했다. 사유가 다르면 예외도 갈라야 라벨이 안 섞인다. + */ +class NoUniqueContentError extends Error { + /** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */ + readonly mismatches: string[] = []; + + constructor(readonly uniqueContentCount: number) { + super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다'); + this.name = 'NoUniqueContentError'; + } +} + /** * 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로 * 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다. @@ -321,10 +339,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType String(value ?? '').trim().length >= MIN_UNIQUE_TEXT; let count = 0; + // 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다. + // ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다. + const intro = payload.theme.sections.find((section) => section.id === 'intro'); + if (intro?.enabled && long(intro.body)) count += 1; + // 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다. for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) { if (fact.type === 'text' && long(fact.value)) count += 1; @@ -585,11 +605,14 @@ function main() { } catch (ex) { // ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 — // 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다. + // ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면 + // 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다. + const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null; fail( entry, ex instanceof Error ? ex.message : String(ex), - ex instanceof VerifyError ? ex.mismatches : [], - ex instanceof VerifyError ? ex.uniqueContentCount : null, + counted?.mismatches ?? [], + counted?.uniqueContentCount ?? null, ); } } diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index a7c4861..040a87e 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -121,6 +121,12 @@ export function isSectionEnabled(payload: SitePayload, id: string): boolean { return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false; } +/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */ +export function sectionBody(payload: SitePayload, id: string): string[] { + const body = payload.theme.sections.find((section) => section.id === id)?.body; + return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? []; +} + export function unitSpec(payload: SitePayload) { return UNIT_SPEC[payload.place.category]; } diff --git a/solution/site/src/sections/AboutSection.tsx b/solution/site/src/sections/AboutSection.tsx index ff3e692..8c74e49 100644 --- a/solution/site/src/sections/AboutSection.tsx +++ b/solution/site/src/sections/AboutSection.tsx @@ -1,10 +1,14 @@ import {useSite} from '@/lib/site-context'; -import {galleryImages} from '@/lib/derive'; +import {galleryImages, sectionBody} from '@/lib/derive'; export function AboutSection() { const payload = useSite(); const {narrative, place} = payload; - if (narrative.about.length === 0) return null; + // 직접 입력한 소개가 있으면 그 문장이 이긴다. 없을 때만 수집·승인된 intro fact 로 떨어진다. + // ★ 에디터 본문을 계수만 하고 화면에 안 내면 빈 페이지가 발행 게이트를 통과한다. + const paragraphs = sectionBody(payload, 'intro'); + const about = paragraphs.length > 0 ? paragraphs : narrative.about; + if (about.length === 0) return null; // 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게. const image = galleryImages(payload).find((m) => !m.isPrimary); @@ -46,7 +50,7 @@ export function AboutSection() {

- {narrative.about.map((paragraph, index) => ( + {about.map((paragraph, index) => (

{paragraph}

))}
From 38fbe0ee7b33f771f8d521c41858f357091115ce Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 15:17:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[feat]=20solution/frontend:=20=EC=97=AC?= =?UTF-8?q?=ED=96=89=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C=20=E2=80=94=20=EB=8C=80=ED=95=A9=EC=8B=A4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가요다방·일력·승차권에 이어 네 번째 붙여넣기 아이템이다. 승차권(course)은 '어디를 도는가'라 순번이 축인데, 손님이 실제로 묻는 건 '몇 시에 뭘 하나'다. 시각을 축으로 하는 칸이 없었다. - dataSpec/schedule: ScheduleItem·ScheduleSlot 과 작성 규칙. time 은 "HH:MM" 만, 장소에 url 대신 searchQuery — 지어낸 주소를 링크하지 않는 규약 그대로다 - registry/schedule.timetable: 역 대합실 플립보드(가운데 접힘선)로 시각을 먼저 읽힌다 - industryData: 레트로 템플릿 시드에 schedule 추가 [+ 섹션 추가] 목록은 dataSpec 에서 파생돼(addable.ts) 따로 손댈 곳이 없다. tsc·vite build 통과. --- solution/frontend/src/data/industryData.ts | 2 +- .../src/features/builder/canvas/dataSpec.ts | 70 +++++++ .../src/features/builder/canvas/registry.ts | 12 ++ .../variants/schedule/ScheduleTimetable.tsx | 174 ++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index 5e2498c..2134e32 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -103,7 +103,7 @@ function templatesFor( fontStyle: '옛 간판체', look: LOOK.retro, // 이 템플릿이 팔려는 게 바로 이 아이템들이다. - defaultSectionTypes: ['songs', 'daily', 'course'], + defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'], }, ]; } diff --git a/solution/frontend/src/features/builder/canvas/dataSpec.ts b/solution/frontend/src/features/builder/canvas/dataSpec.ts index 5a5b0e0..7b1fd77 100644 --- a/solution/frontend/src/features/builder/canvas/dataSpec.ts +++ b/solution/frontend/src/features/builder/canvas/dataSpec.ts @@ -55,6 +55,26 @@ export interface CourseItem { source?: DataSource; } +export interface ScheduleSlot { + /** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */ + time: string; + title: string; + place?: string; + minutes?: number; + note?: string; + searchQuery?: string; +} + +export interface ScheduleItem { + name: string; + /** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */ + audience?: string; + season?: string; + slots?: ScheduleSlot[]; + verified?: DataVerified; + source?: DataSource; +} + export interface SectionDataSpec { /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ kind: string; @@ -302,6 +322,56 @@ export const SECTION_DATA_SPEC: Record = { · minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다. · 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`, }, + + schedule: { + kind: 'schedule', + label: '여행 스케줄', + requiredKey: 'name', + sample: JSON.stringify( + { + kind: 'schedule', + version: 1, + title: '여행 스케줄', + items: [ + { + name: '비 오는 날의 하루', + audience: '혼자 온 손님', + season: '장마', + slots: [ + {time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'}, + {time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'}, + {time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'}, + {time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'}, + {time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'}, + ], + verified: '확인필요', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'}, + }, + ], + }, + null, + 2, + ), + task: `[해야 할 일] +[업소]에 묵거나 들른 손님이 [지역]에서 하루를 어떻게 보내면 좋을지 여행 스케줄을 2~3개 만든다. +스케줄마다 시간대 5~7개. 아침부터 저녁까지 시각 순서로 배열한다. +스케줄은 서로 성격이 달라야 한다 — 날씨(비 오는 날)·동행(아이와 함께)·계절 중 하나로 가른다. + +[스키마] +{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[ + { "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨", + "slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소", + "minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ], + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· time 은 24시간 "HH:MM" 로만 적는다. "오전 9시" 처럼 쓰지 않는다. +· 그 장소의 영업시간·휴무일을 안다고 가정하지 않는다. 바뀌면 손님이 헛걸음한다. +· 첫 칸은 [업소]에서 시작하고, 이동은 걸어서 또는 대중교통으로 갈 수 있는 범위로 짠다. +· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다. +· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`, + }, }; export function dataSpecFor(sectionType: string): SectionDataSpec | undefined { diff --git a/solution/frontend/src/features/builder/canvas/registry.ts b/solution/frontend/src/features/builder/canvas/registry.ts index c74c1b0..ab28cf0 100644 --- a/solution/frontend/src/features/builder/canvas/registry.ts +++ b/solution/frontend/src/features/builder/canvas/registry.ts @@ -67,6 +67,7 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice'; import {SongsTurntable} from './variants/songs/SongsTurntable'; import {DailyCalendar} from './variants/daily/DailyCalendar'; import {CourseTickets} from './variants/course/CourseTickets'; +import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable'; export const SECTION_VARIANTS: Record = { hero: [ @@ -435,6 +436,17 @@ export const SECTION_VARIANTS: Record = { }, ], + schedule: [ + { + id: 'schedule.timetable', + name: '대합실 시간표', + description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.', + thumb: 'carousel', + Component: ScheduleTimetable, + isDefault: true, + }, + ], + exhibition: [ { id: 'exhibition.gallery', diff --git a/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx b/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx new file mode 100644 index 0000000..deeb7bf --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx @@ -0,0 +1,174 @@ +/** + * 여행 스케줄 — 대합실 시간표 캐러셀. + * + * 칸 하나가 시간대 하나다. 위쪽 검은 판은 역 대합실의 플립보드(가운데 접힘선)를 그대로 옮긴 것 — + * 시각이 먼저 읽히고 무엇을 하는지가 뒤따라야 시간표로 읽힌다. + * ★ 승차권(course)과 일부러 다르게 짰다. 저쪽은 '어디를 도는가'(순번), 여기는 '언제 무엇을'(시각)이다. + * ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약. + */ +import {SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec'; +import { + CarouselNav, + ParseError, + PasteHint, + RETRO_BODY, + RETRO_INK, + RETRO_LINE, + RETRO_PAPER_LIGHT, + RETRO_RED, + RETRO_SIGN, + SourceLine, + useCarousel, +} from '../retro/common'; +import '../retro/retro.css'; + +/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */ +function splitTime(time: string): {head: string; tail?: string} { + const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!match) return {head: time.trim()}; + return {head: match[1].padStart(2, '0'), tail: match[2]}; +} + +function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) { + const {head, tail} = splitTime(slot.time ?? ''); + + return ( +
+
+ {/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */} +
+ + {head} + {tail && :{tail}} + + +
+ +
+

+ {slot.title} +

+ {slot.place && ( +

+ {slot.place} +

+ )} + {slot.note && ( +

+ {slot.note} +

+ )} +
+ +
+ {slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'} + {slot.searchQuery && 지도 검색 · {slot.searchQuery}} +
+
+ + {/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */} + {!isLast && ( +
+ +
+ )} +
+ ); +} + +function ScheduleRow({schedule}: {schedule: ScheduleItem}) { + const {ref, scrollBy} = useCarousel(); + const slots = schedule.slots ?? []; + const span = + slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined; + + return ( +
+
+
+

+ {schedule.name} +

+ + {[schedule.audience, schedule.season, span, `${slots.length}칸`] + .filter(Boolean) + .join(' · ')} + +
+ {slots.length > 2 && ( + scrollBy(-1)} onNext={() => scrollBy(1)} label={schedule.name} /> + )} +
+ + {slots.length === 0 ? ( +

+ 시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요. +

+ ) : ( +
+ {slots.map((slot, index) => ( + + ))} +
+ )} + + +
+ ); +} + +export function ScheduleTimetable(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + + return ( + + +
+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : parsed.items.length === 0 ? ( + + ) : ( +
+ {parsed.items.map((schedule, index) => ( + + ))} +
+ )} +
+
+ ); +}