From 9773bc04966def399804bb63a45beaa4f2d82121 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 15 Sep 2026 10:22:26 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution:=20=EC=97=BD=EC=84=9C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=C2=B7=ED=8C=90=20=EC=83=89=C2=B7=ED=91=B8?= =?UTF-8?q?=ED=84=B0=20=ED=91=9C=EA=B8=B0,=20=EC=9E=AC=EB=B0=9C=ED=96=89?= =?UTF-8?q?=EC=9D=B4=20=EB=85=B8=EB=9E=98=EB=A5=BC=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EC=A7=80=20=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대표 지적 여섯을 한 번에 고친다. - **재발행이 Suno 를 다시 부르던 것**(`song_service.ensure_song`). `build_service` 가 `publish=True` 마다 이 함수를 불렀는데 가드가 없었다 — 내용이 하나도 안 바뀐 재발행에도 Gemini 가사 1회 + Suno 작곡 1회가 그대로 나갔고, 다섯 번 누르면 유료 호출 다섯 번에 `place_songs` 행 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로 나머지는 돈만 쓴다. → READY 곡이 있으면 건너뛴다. 일부러 다시 만드는 길은 SONG 잡(`force=True`)으로 남긴다 - **엽서 쓰기 자리**를 계절별 축제 바로 앞으로(시연본과 같게). 목록 맨 뒤라 FAQ 보다도 아래였다. 축제가 없는 사이트는 종전대로 맨 뒤 - **엽서가 모바일에서 화면을 밀어내던 것** — 그리드 칸의 기본 `min-width:auto` 가 사진 레일의 최소 너비(실측 390px 화면에서 2,056px)를 그대로 받아 칸이 밖으로 나갔다. `min-w-0` 과 캔버스 비율 고정(`aspect-square h-auto`). 실측 넘침 0px · 캔버스 358×358 - **가요 다방 판이 한 색이던 것** — 폴백 팔레트를 시연본(`/s/stay`)이 쓰는 23색 그대로 옮겼다(대표: "그냥 정해놔 · /s/stay 보고 맞춰라"). 목록을 새로 짜면 시연본과 갈린다 - **푸터에 만든 곳 표기** — `AI O2O의 Web4Ai로 만든 사이트입니다.`(o2osolution.ai 링크). 발행본(`SiteFooter`)과 시연본(`patch_stay.py`) 둘 다 - **에디터 캔버스의 푸터·모바일 탭바 제거** — 발행본이 iframe 안에서 둘 다 이미 그린다 (`SiteFooter` · `MobileTabBar`). 바깥에 한 벌 더 그려 두 번 서 있었다 - **시연본 자동재생 끔**(`inject.js`, 대표: "클릭하고 나서 되니까 사람들이 에러로 보잖아"). 세 번째 뒤집기고 이번 이유는 다르다 — 정책을 못 이겨서가 아니라 정책에 걸린 모습이 고장으로 읽혀서다. 함수는 남기고 호출만 뺐다. 제품 렌더러엔 원래 없다 검증: tsc·eslint 통과(site·frontend), site 79 passed. 실제로 구워서 확인 — 섹션 순서 `…오시는 길 → 엽서쓰기 → 계절별 축제 → …`, 푸터에 o2osolution.ai 링크, 모바일 가로 넘침 0. 킹서버 실측: 재발행 안 한 사이트(joyyy·bbbb)도 중계로 공유 버튼이 뜬다. Co-Authored-By: Claude Opus 5 (1M context) --- solution/backend/services/song_service.py | 24 ++++++++- .../src/features/builder/CanvasView.tsx | 51 +++---------------- solution/site/scripts/mockup/AUTOPLAY.md | 5 ++ solution/site/scripts/mockup/README.md | 5 +- solution/site/scripts/mockup/inject.js | 12 ++++- solution/site/scripts/mockup/patch_stay.py | 17 +++++++ solution/site/src/pages/HomePage.tsx | 22 ++++++-- solution/site/src/sections/SiteFooter.tsx | 19 +++++++ .../sections/items/PostcardMakerSection.tsx | 14 +++-- .../site/src/sections/items/SongsSection.tsx | 19 +++---- 10 files changed, 123 insertions(+), 65 deletions(-) diff --git a/solution/backend/services/song_service.py b/solution/backend/services/song_service.py index ba99a3f..4faae8c 100644 --- a/solution/backend/services/song_service.py +++ b/solution/backend/services/song_service.py @@ -128,12 +128,21 @@ async def run_song(job: dict) -> dict: 발행 경로는 이 잡을 거치지 않고 `ensure_song` 을 직접 부른다(build_service).""" payload = job["payload"] - return await ensure_song(payload["place_id"], payload["owner_user_id"]) + # ★ 이 잡은 "다시 만들어 달라" 는 요청이다 — 기존 곡이 있어도 만든다(ensure_song 주석). + return await ensure_song(payload["place_id"], payload["owner_user_id"], force=True) -async def ensure_song(place_id: str, owner_user_id: str) -> dict: +async def ensure_song(place_id: str, owner_user_id: str, *, force: bool = False) -> dict: """이 업장의 노래를 한 곡 만든다. 돌려주는 dict 가 곧 잡 결과이자 빌드 로그다. + ★ **이미 완성된 곡이 있으면 만들지 않는다**(2026-09-15 대표: "재발행할 때 노래 다시 + 생성하면 안 되거든"). 예전에는 `build_service` 가 `publish=True` 마다 이 함수를 불렀고 + 여기에 가드가 없어서, 내용이 하나도 안 바뀐 재발행에도 **Gemini 가사 1회 + Suno 작곡 + 1회**가 그대로 나갔다. 사장님이 발행을 다섯 번 누르면 유료 호출 다섯 번에 + `place_songs` 행이 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로(`SongCRUD.latest_ready`) + 나머지는 값을 만들지 않고 돈만 쓴다. + ★ 곡을 **일부러 다시 만드는 길은 남긴다** — `force=True`. 그 길은 발행이 아니라 + SONG 잡(`run_song`)이고, 사람이 눌러야 돈다. ★ 실패를 예외로 올리지 않는다(사업장을 못 찾는 것 같은 진짜 고장만 예외다). 발행이 이 함수를 기다리는데 여기서 예외가 나면 **노래 때문에 발행이 통째로 실패**한다. 음악 API 가 느린 날 사장님 사이트가 안 나가는 것은 맞바꿀 수 없는 손해다.""" @@ -142,6 +151,17 @@ async def ensure_song(place_id: str, owner_user_id: str) -> dict: LOG.i(f"[song] place={place_id} 건너뜀 — SUNO_API_KEY 미설정") return {"place_id": place_id, "skipped": "SUNO_API_KEY 미설정"} + if not force: + err, ready = await DB_SESSION_MNG.execute_lambda( + place_songs.DBType(), + DBWRType.DB_READ.value, + lambda s: _song_crud.latest_ready(s, uuid.UUID(place_id)), + ) + # `execute()` 는 리스트를 준다(limit 1 이라 0개 아니면 1개다) — None 비교로 보지 않는다. + if err == ErrorType.SUCCESS and ready: + LOG.i(f"[song] place={place_id} 건너뜀 — 이미 곡이 있다(재발행은 다시 만들지 않는다)") + return {"place_id": place_id, "skipped": "이미 곡이 있다"} + err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index ee1b058..5335279 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -141,51 +141,12 @@ export function CanvasView() { }} /> -
-
-

- {storeName} -

-

- {infoFields.find((f) => f.id === 'address')?.value || location} -

-

- © {new Date().getFullYear()} {storeName}. Powered by Web4Ai. -

-
-
- - {viewport === 'mobile' && ( - - )} + {/* ★ 푸터도 모바일 하단 탭바도 여기서 그리지 않는다 + (2026-09-15 대표: "에디터에서 이 부분 필요없음 / 이 부분 자체가 필요없음") + 발행본이 iframe 안에서 **둘 다 이미 그린다** — 푸터는 `site/sections/SiteFooter.tsx`, + 탭바는 `site/sections/MobileTabBar.tsx`(App 과 레이아웃 Shell 다섯이 모두 세운다). + 바깥에 한 벌 더 그리면 상호·주소·저작권·탭이 두 번 서고, 두 벌의 값이 어긋날 수도 + 있다 — 이 캔버스가 "발행되는 그 화면"이어야 한다는 원칙(SitePreview 주석)에 어긋난다. */} diff --git a/solution/site/scripts/mockup/AUTOPLAY.md b/solution/site/scripts/mockup/AUTOPLAY.md index e06c3aa..666faf6 100644 --- a/solution/site/scripts/mockup/AUTOPLAY.md +++ b/solution/site/scripts/mockup/AUTOPLAY.md @@ -1,5 +1,10 @@ # 자동재생 — 안 된다. 왜 안 되는지와, 그래서 뭘 했는지 +> ★ **2026-09-15 — 자동재생을 껐다.** 대표: "노래가 자동재생 안 되고 클릭하고 나서 되니까 +> 사람들이 에러로 보잖아 그냥 꺼놔." 아래 내용은 **왜 자동재생이 어려운가**의 기록으로 남긴다 +> — 되살릴 일이 생기면 `inject.js` 의 `void autoplay;` 를 `autoplay();` 로 되돌리면 된다. +> 함수와 첫 손길 처리는 지우지 않았다. + `/s/stay` 배경음악을 **새로고침 직후 바로** 나게 하려고 시도한 것 전부와 실측값이다. 같은 얘기가 다시 나오면 이 파일부터 본다. diff --git a/solution/site/scripts/mockup/README.md b/solution/site/scripts/mockup/README.md index 4369b30..59a1cb5 100644 --- a/solution/site/scripts/mockup/README.md +++ b/solution/site/scripts/mockup/README.md @@ -935,7 +935,10 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" 1. **캐치프레이즈 순환** — 대표 문구 `

` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1 2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡. - **자동재생**은 걸어 보고, 브라우저가 막으면 첫 손길에 켠다(**1.3 T4**) → T4 + ★ **자동재생은 껐다** (2026-09-15 대표: "자동재생 안 되고 클릭하고 나서 되니까 사람들이 + 에러로 보잖아 그냥 꺼놔"). 세 번째 뒤집기다(09-11 빼 → 09-14 켜 → 09-15 끔). 이번 이유는 + 앞의 둘과 다르다 — 정책을 못 이겨서가 아니라, **정책에 걸린 그 모습이 고장으로 읽혀서**다. + `inject.js` 의 `autoplay()` 는 남아 있고 호출만 뺐다(`void autoplay;`) → T4 3. **군산 읽기** — ★ **2026-09-15 부터 렌더러에 있다**(`ReadingSection.tsx`, **T7**). 아래는 시연본 주입분이 어떻게 굴러가는지의 기록이고, 제품을 고칠 때는 렌더러를 본다. '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가 diff --git a/solution/site/scripts/mockup/inject.js b/solution/site/scripts/mockup/inject.js index 0da22a7..d58810b 100644 --- a/solution/site/scripts/mockup/inject.js +++ b/solution/site/scripts/mockup/inject.js @@ -569,7 +569,17 @@ buildList(); mount(); render(); - autoplay(); + /* ★ 자동재생을 **끈다** (2026-09-15 대표: "노래가 자동재생 안 되고 클릭하고 나서 되니까 + 사람들이 에러로 보잖아 그냥 꺼놔"). 세 번째 뒤집기다 — 09-11 "자동 빼" → 09-14 "자동재생 + 한다" → 09-15 끈다. 뒤집은 이유가 매번 다르다: 이번은 **정책을 못 이겨서**가 아니라 + 정책에 걸린 그 모습이 고장으로 읽혀서다. 브라우저는 손님이 페이지를 만지기 전에는 + 소리를 거절하므로(정책), 자동재생을 켜 두면 "눌러야 나는 플레이어"가 되고 그건 + 고장과 구별되지 않는다. + ★ 함수는 남긴다 — 네 번째 뒤집기가 오면 이 한 줄만 되살린다. 지운 자리를 다시 짜면 + 첫 손길 리스너의 클릭 삼킴(2026-09-11)을 또 밟는다. + ★ 제품 렌더러(`site/src/sections/SongPlayer.tsx`)에는 원래 자동재생이 없다 — + 이제 목업과 제품이 같은 동작이다. */ + void autoplay; if (window.MutationObserver) { new MutationObserver(function () { if (!mini.isConnected) { mount(); render(); } diff --git a/solution/site/scripts/mockup/patch_stay.py b/solution/site/scripts/mockup/patch_stay.py index 570ddba..0eb43d8 100644 --- a/solution/site/scripts/mockup/patch_stay.py +++ b/solution/site/scripts/mockup/patch_stay.py @@ -441,6 +441,23 @@ html = html.replace(OLD_UPDATED_AT, UPDATED_AT) html = html.replace(f">{OLD_UPDATED_AT[:10]}", f">{UPDATED_AT[:10]}") assert OLD_UPDATED_AT not in html and OLD_UPDATED_AT[:10] not in html, "옛 날짜가 남았다" +# ── 푸터 '만든 곳' 한 줄 ────────────────────────────────────────────────────── +# ★ 왜 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈 목업 빌더 다 똑같아") +# 렌더러가 박는 줄이라(`SiteFooter.tsx`) 굽는 사이트에는 전부 들어가는데, 이 한 장은 +# 2026-09-09 판 프리렌더 결과물(`orig/index.html`)이라 그 줄이 없다. +# ★ 마크업은 렌더러가 내보내는 것과 **글자 하나까지 같아야** 한다 — 정적 HTML 위에 +# 하이드레이션이 얹히므로(entry-client.tsx) 어긋나면 React 가 그 자리를 다시 그린다. +# 위 문자열은 `render(payload)` 출력에서 그대로 떠 온 것이다(속성 순서까지). +# ★ 화면이 실제로 따라오는 것은 `vendor/` 번들을 다시 복사한 다음이다(아래 ★ 갱신 절차) — +# 지금 넣는 것은 크롤러와 첫 화면이 보는 값이고, 날짜를 payload 밖에서도 고치는 것과 +# 같은 이유다. +MADE_BY = ('

' + 'AI O2O' + '의 Web4Ai로 만든 사이트입니다.

') +html, n = re.subn(r"", lambda _m: MADE_BY + "", html, count=1) +assert n == 1, "푸터 끝()을 못 찾았다" + # ★ 이용 정보 **맨 아래 구분선**을 지운다 (2026-09-10 사장님 지시). # 항목이 끝난 자리에 선이 한 줄 더 그어져 있어, 빈 칸이 하나 더 있는 것처럼 보인다. # 항목(바비큐 이용) 자체는 그대로 둔다 — 지우라고 한 것은 선이다. diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index 767a755..e1d54e4 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -84,6 +84,20 @@ export function HomePage() { const rendered = new Set(); + /* + * 엽서 쓰기가 설 자리 — **계절별 축제 바로 앞**이다 (2026-09-14 대표: "이건 축제소개 전에 + * 섹션으로 빼줘", 2026-09-15: "목업처럼 순서 바꿔"). + * + * ★ 섹션 설정(`theme.sections`)에 자리가 없는 기능이라 목록을 따라 그릴 수 없다. 그래서 + * **축제 바로 앞에 끼워 넣고**, 축제가 꺼져 있거나 없는 사이트에서는 맨 뒤로 보낸다 — + * 자리를 못 찾았다고 기능을 통째로 빠뜨리지 않는다. + * ★ 예전에는 목록을 다 그린 **뒤** 맨 아래에 뒀다. 그러면 FAQ·예약 안내보다도 밑이라 + * 시연본과 순서가 달랐다(시연본은 축제 앞). + */ + const festivalFirst = payload.theme.sections.some( + (section) => section.enabled && section.id === 'festival' && SECTION_COMPONENTS.festival, + ); + return (
@@ -106,16 +120,16 @@ export function HomePage() { * 화면 id(`gallery`)와 설정 id(`photos`)가 달라서 필요하다. */
+ {/* 사진이 한 장도 없으면 만들 엽서가 없으므로 컴포넌트가 스스로 빠진다. */} + {section.id === 'festival' && } {section.id === 'intro' && }
); })} - {/* 엽서 쓰기 — 사진이 있으면 낸다 (2026-09-14 대표: "웹빌드 해도 엽서쓰기 나오게"). - 섹션 설정에 자리가 없는 기능이라 오시는 길과 같은 방식으로 여기서 직접 낸다. - 사진이 한 장도 없으면 만들 엽서가 없으므로 컴포넌트가 스스로 빠진다. */} - + {/* 축제 섹션이 없는 사이트 — 앞에 끼울 자리가 없었으니 여기서 낸다. */} + {!festivalFirst && } {/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */} {!isSectionEnabled(payload, 'map') && } diff --git a/solution/site/src/sections/SiteFooter.tsx b/solution/site/src/sections/SiteFooter.tsx index 9ab1b34..61a4add 100644 --- a/solution/site/src/sections/SiteFooter.tsx +++ b/solution/site/src/sections/SiteFooter.tsx @@ -112,6 +112,25 @@ export function SiteFooter() {

+ + {/* + ★ 만든 곳 표기 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈"). + payload 가 아니라 렌더러가 박는다 — 사장님이 어떤 템플릿을 골라도, 어떤 값을 + 비워 두어도 모든 발행본에 같은 자리로 나와야 하는 한 줄이다. + ★ 위 두 칸(사업자 표기·byline)과 같은 활자·불투명도를 쓴다. 이 줄이 눈에 띄면 + 사장님 사이트가 아니라 우리 사이트로 보인다. + */} +

+ + AI O2O + + 의 Web4Ai로 만든 사이트입니다. +

); diff --git a/solution/site/src/sections/items/PostcardMakerSection.tsx b/solution/site/src/sections/items/PostcardMakerSection.tsx index 58d3e4a..5f980b2 100644 --- a/solution/site/src/sections/items/PostcardMakerSection.tsx +++ b/solution/site/src/sections/items/PostcardMakerSection.tsx @@ -122,14 +122,22 @@ export function PostcardMakerSection() { width={POSTCARD_SIZE} height={POSTCARD_SIZE} aria-label="내가 만든 엽서 미리보기" - className="border-line mx-auto w-full max-w-[360px] border" + /* ★ 비율을 CSS 로 못 박는다 (2026-09-15 대표: "모바일에서 깨지잖아"). + `` 에 width 만 주면 브라우저에 따라 **높이가 속성값(1080px) 그대로** 남아 + 1080 폭짜리 그림이 좁은 폭에 눌린 채 늘어난다. `aspect-square h-auto` 로 못 박으면 + 어느 브라우저에서도 정사각이다. `block` 은 inline 기준선 아래 생기는 틈을 없앤다. */ + className="border-line mx-auto block aspect-square h-auto w-full max-w-[360px] border" /> -
+ {/* ★ `min-w-0` 이 없으면 모바일에서 칸이 통째로 밀려난다 (2026-09-15 대표: "모바일에서 + 엽서 넘침"). 그리드 칸의 기본 `min-width:auto` 는 **안에 든 것의 최소 너비**다 — + 사진 레일은 썸네일을 옆으로 죽 늘어놓으므로 그 값이 2,000px 을 넘었고(실측 390px + 화면에서 칸이 2056px), 칸이 화면 밖으로 나갔다. 스크롤 상자를 품은 칸에는 늘 붙인다. */} +
{/* ★ 이 사이트가 쓰는 캐러셀을 그대로 쓴다 (2026-09-15 대표: "왜 이미지 리스트에 slide js 안 걸려 있음?"). 처음엔 `.slider-viewport`·`.slider-track` 마크업만 손으로 찍었는데, 그 둘은 **스크립트가 붙기 전의 기본 상태**다(Carousel.tsx 주석) — 손가락·트랙패드로만 밀리고 휠 마우스로는 옆에 더 있는 줄도 모른다. */} -
+
{photos.map((photo, i) => (