[fix] solution: 엽서 자리·판 색·푸터 표기, 재발행이 노래를 다시 만들지 않게

대표 지적 여섯을 한 번에 고친다.

- **재발행이 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) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-15 10:22:26 +09:00
parent f0c4d5f413
commit 9773bc0496
10 changed files with 123 additions and 65 deletions

View File

@ -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,

View File

@ -141,51 +141,12 @@ export function CanvasView() {
}}
/>
<footer
className="border-t border-black/10 p-6 text-center text-xs"
style={{backgroundColor: template.colors.bg, color: template.colors.secondary}}
>
<div className="mx-auto max-w-4xl space-y-2">
<p className="font-bold" style={{color: template.colors.text}}>
{storeName}
</p>
<p className="text-[11px]">
{infoFields.find((f) => f.id === 'address')?.value || location}
</p>
<p className="pt-2 text-[10px] opacity-60">
© {new Date().getFullYear()} {storeName}. Powered by Web4Ai.
</p>
</div>
</footer>
{viewport === 'mobile' && (
<nav
aria-label="모바일 하단 내비게이션 미리보기"
className="sticky inset-x-0 bottom-0 z-30 flex items-center justify-around border-t border-black/10 bg-white/95 px-2 py-1.5 backdrop-blur-md"
>
{MOBILE_TABS.map(({key, label, anchor, icon: Icon}) => (
<button
key={key}
type="button"
onClick={() => scrollToSection(anchor, key)}
className={cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg px-2 py-1 transition-all',
activeMobileTab === key ? 'font-bold text-zinc-950' : 'text-zinc-500',
)}
>
<Icon className="size-4" />
<span className="mt-0.5 text-[10px]">{label}</span>
</button>
))}
<span
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-[11px] font-bold text-white"
style={{backgroundColor: template.colors.primary}}
>
<Calendar className="size-3.5" />
<span>예약하기</span>
</span>
</nav>
)}
{/* ★ 푸터도 모바일 하단 탭바도 여기서 그리지 않는다
(2026-09-15 대표: "에디터에서 이 부분 필요없음 / 이 부분 자체가 필요없음")
발행본이 iframe 안에서 **둘 다 이미 그린다** — 푸터는 `site/sections/SiteFooter.tsx`,
탭바는 `site/sections/MobileTabBar.tsx`(App 과 레이아웃 Shell 다섯이 모두 세운다).
바깥에 한 벌 더 그리면 상호·주소·저작권·탭이 두 번 서고, 두 벌의 값이 어긋날 수도
있다 — 이 캔버스가 "발행되는 그 화면"이어야 한다는 원칙(SitePreview 주석)에 어긋난다. */}
</div>
</div>
</div>

View File

@ -1,5 +1,10 @@
# 자동재생 — 안 된다. 왜 안 되는지와, 그래서 뭘 했는지
> ★ **2026-09-15 — 자동재생을 껐다.** 대표: "노래가 자동재생 안 되고 클릭하고 나서 되니까
> 사람들이 에러로 보잖아 그냥 꺼놔." 아래 내용은 **왜 자동재생이 어려운가**의 기록으로 남긴다
> — 되살릴 일이 생기면 `inject.js` 의 `void autoplay;` 를 `autoplay();` 로 되돌리면 된다.
> 함수와 첫 손길 처리는 지우지 않았다.
`/s/stay` 배경음악을 **새로고침 직후 바로** 나게 하려고 시도한 것 전부와 실측값이다.
같은 얘기가 다시 나오면 이 파일부터 본다.

View File

@ -935,7 +935,10 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
1. **캐치프레이즈 순환** — 대표 문구 `<p>` 를 찾아 **그 아래에 줄(`#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**). 아래는
시연본 주입분이 어떻게 굴러가는지의 기록이고, 제품을 고칠 때는 렌더러를 본다.
'군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가

View File

@ -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(); }

View File

@ -441,6 +441,23 @@ html = html.replace(OLD_UPDATED_AT, UPDATED_AT)
html = html.replace(f">{OLD_UPDATED_AT[:10]}</time>", f">{UPDATED_AT[:10]}</time>")
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 = ('<p class="pt-3 text-[length:var(--fs-xs)] opacity-55">'
'<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer" '
'class="underline-offset-2 hover:underline">AI O2O</a>'
'의 Web4Ai로 만든 사이트입니다.</p>')
html, n = re.subn(r"</div></footer>", lambda _m: MADE_BY + "</div></footer>", html, count=1)
assert n == 1, "푸터 끝(</div></footer>)을 못 찾았다"
# ★ 이용 정보 **맨 아래 구분선**을 지운다 (2026-09-10 사장님 지시).
# 항목이 끝난 자리에 선이 한 줄 더 그어져 있어, 빈 칸이 하나 더 있는 것처럼 보인다.
# 항목(바비큐 이용) 자체는 그대로 둔다 — 지우라고 한 것은 선이다.

View File

@ -84,6 +84,20 @@ export function HomePage() {
const rendered = new Set<string>();
/*
* 엽서 쓰기가 설 자리 — **계절별 축제 바로 앞**이다 (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 (
<main className="w-full flex-1">
<HeroSection />
@ -106,16 +120,16 @@ export function HomePage() {
* 화면 id(`gallery`)와 설정 id(`photos`)가 달라서 필요하다.
*/
<div key={section.id} data-editor-id={section.id} style={{display: 'contents'}}>
{/* 사진이 한 장도 없으면 만들 엽서가 없으므로 컴포넌트가 스스로 빠진다. */}
{section.id === 'festival' && <PostcardMakerSection />}
<Component />
{section.id === 'intro' && <AnswerBlock />}
</div>
);
})}
{/* 엽서 쓰기 — 사진이 있으면 낸다 (2026-09-14 대표: "웹빌드 해도 엽서쓰기 나오게").
섹션 설정에 자리가 없는 기능이라 오시는 길과 같은 방식으로 여기서 직접 낸다.
사진이 한 장도 없으면 만들 엽서가 없으므로 컴포넌트가 스스로 빠진다. */}
<PostcardMakerSection />
{/* 축제 섹션이 없는 사이트 — 앞에 끼울 자리가 없었으니 여기서 낸다. */}
{!festivalFirst && <PostcardMakerSection />}
{/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */}
{!isSectionEnabled(payload, 'map') && <LocationSection />}

View File

@ -112,6 +112,25 @@ export function SiteFooter() {
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
</p>
</div>
{/*
★ 만든 곳 표기 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈").
payload 가 아니라 렌더러가 박는다 — 사장님이 어떤 템플릿을 골라도, 어떤 값을
비워 두어도 모든 발행본에 같은 자리로 나와야 하는 한 줄이다.
★ 위 두 칸(사업자 표기·byline)과 같은 활자·불투명도를 쓴다. 이 줄이 눈에 띄면
사장님 사이트가 아니라 우리 사이트로 보인다.
*/}
<p className="pt-3 text-[length:var(--fs-xs)] opacity-55">
<a
href="https://www.o2osolution.ai/"
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 hover:underline"
>
AI O2O
</a>
의 Web4Ai로 만든 사이트입니다.
</p>
</div>
</footer>
);

View File

@ -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 대표: "모바일에서 깨지잖아").
`<canvas>` 에 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"
/>
<div className="flex flex-col gap-2.5">
{/* ★ `min-w-0` 이 없으면 모바일에서 칸이 통째로 밀려난다 (2026-09-15 대표: "모바일에서
엽서 넘침"). 그리드 칸의 기본 `min-width:auto` 는 **안에 든 것의 최소 너비**다 —
사진 레일은 썸네일을 옆으로 죽 늘어놓으므로 그 값이 2,000px 을 넘었고(실측 390px
화면에서 칸이 2056px), 칸이 화면 밖으로 나갔다. 스크롤 상자를 품은 칸에는 늘 붙인다. */}
<div className="flex min-w-0 flex-col gap-2.5">
{/* ★ 이 사이트가 쓰는 캐러셀을 그대로 쓴다 (2026-09-15 대표: "왜 이미지 리스트에
slide js 안 걸려 있음?"). 처음엔 `.slider-viewport`·`.slider-track` 마크업만
손으로 찍었는데, 그 둘은 **스크립트가 붙기 전의 기본 상태**다(Carousel.tsx 주석)
— 손가락·트랙패드로만 밀리고 휠 마우스로는 옆에 더 있는 줄도 모른다. */}
<div role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`}>
<div className="min-w-0" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`}>
<Carousel label="엽서 사진" gap={0.5} dots={false} autoplay={false}>
{photos.map((photo, i) => (
<button

View File

@ -33,19 +33,20 @@ import {
* ("확인되지 않은 값은 필드를 통째로 뺀다")를 색에까지 적용해 버린다. 결과는
* 판 스물다섯 장이 전부 같은 주朱색인 진열대였다. 색은 사실이 아니라 디자인 값이라
* **비어 있어도 화면은 달라야 한다.**
* ★ 옛 레코드 라벨에서 고른 진한 색들이다. 어두운 판 위에 얹히므로 밝은 색은 안 쓴다.
* ★ **색은 시연본(`/s/stay`)이 쓰는 그 23색 그대로다** (2026-09-15 대표: "그냥 정해놔 ·
* /s/stay 보고서 맞춰라"). 시연본은 25곡에 색이 다 붙어 있어 판 진열대가 알록달록한데,
* 제품은 그 값이 비어 한 색으로 깔렸다. 목록을 새로 짜지 않고 **그 팔레트를 옮겨 왔다** —
* 두 벌이 되면 시연본과 제품의 판 색이 갈린다. 원본은
* `site/scripts/mockup/stay-payload-new.json` 의 `songs[].labelColor` 다.
* ★ 제목으로 고른다 — **무작위가 아니다.** 서버에서 구운 색과 브라우저가 고른 색이
* 달라지면 하이드레이션이 어긋나고, 새로고침마다 판 색이 바뀌면 그것도 우스워진다.
*/
const LABEL_PALETTE = [
'#8b1e3f', // 버건디
'#1f4e79', // 감청
'#2f5d30', // 짙은 초록
'#a8541a', // 주황갈색
'#5a3a22', // 고동
'#0f5d7a', // 청록
'#6b2d5c', // 자주
'#7a6216', // 겨자
'#d4551f', '#1d3c74', '#2a6053', '#7a3b52', '#a34a2a',
'#334b6d', '#4f6b3a', '#6b2f3a', '#2b6b7a', '#8a4b2a',
'#4a3f6b', '#3a5a4a', '#b5603a', '#356b8a', '#6b4a2a',
'#2f5b6b', '#a3562a', '#4a4a6b', '#c8873a', '#2f6b4f',
'#7a4a6b', '#5a5a4a', '#3f5f6b',
];
/** 제목 → 팔레트 index. 짧고 안정적이면 된다(암호학적 용도가 아니다). */