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() {
}}
/>
-
-
- {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 = ('
')
+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)과 같은 활자·불투명도를 쓴다. 이 줄이 눈에 띄면
+ 사장님 사이트가 아니라 우리 사이트로 보인다.
+ */}
+