[feat] solution,postgres-init: 발행하면 이 숙소의 노래가 한 곡 생긴다 — 가사 Gemini · 작곡 Suno

/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한
사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.

★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 —
  먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
  값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다.
  단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이
  실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.

★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는
  것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서
  노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다.
  가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가
  없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다.

★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히
  죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다.
★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면
  "요청은 성공했는데 결과가 영영 안 옴" 이 된다.

- services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기
- services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르
- services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다
- build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료)
- place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 —
  수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus)
- snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다
- prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다**
  (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다)
- site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다.
  패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가
  못 읽는다(오디오 안의 말은 어차피 못 듣는다)
- azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 —
  업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다
- compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL

검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1)
→ 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인,
지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed
(실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-11 11:21:16 +09:00
parent 703f831273
commit 38bb4be9e5
29 changed files with 1195 additions and 5 deletions

View File

@ -36,6 +36,13 @@ KAKAO_REST_API_KEY=
GEMINI_API_KEY=
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
TOUR_API_KEY=
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
# 비우면 그 단계만 건너뛴다 — 발행은 그대로 된다.
# ★ 콜백은 쓰지 않고 폴링한다(우리 서버는 Suno 가 닿을 수 있는 주소가 아니다).
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
SUNO_API_KEY=
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)

View File

@ -49,6 +49,9 @@ services:
SCHEDULER_ENABLED: "1"
volumes:
- ./solution/site/payloads:/app/out/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/out/songs
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
- ./postgres-init:/app/postgres-init:ro
@ -81,6 +84,9 @@ services:
disable: true
volumes:
- ./solution/site/payloads:/app/out/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/out/songs
- site-out:/app/out/sites:ro
extra_hosts:
- "host.docker.internal:host-gateway"
@ -119,6 +125,9 @@ services:
retries: 3
volumes:
- ./solution/site/payloads:/app/out/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/out/songs
ports:
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"

View File

@ -13,8 +13,14 @@
```
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
out/payloads/.status/<slug>.json ──보고──▶
──쓴다──▶ out/songs/<song_id>.mp3 ◀──복사──
```
★ 노래(mp3)도 **같은 약속**을 쓴다(2026-09-11). 백엔드는 발행물 디렉토리를 모른 채 파일을
`out/songs/` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다. 백엔드가 발행물
디렉토리에 직접 쓰기 시작하면 이 경계가 무너진다 — 그때부터 두 쪽이 out/ 의 모양을 함께
알아야 한다.
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.

View File

@ -13,7 +13,7 @@
---
## 0. 표 14개, 스키마는 `public` 한 벌
## 0. 표 15개, 스키마는 `public` 한 벌
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
@ -27,13 +27,14 @@ users 사장님 계정
│ ├ place_photos 사진
│ ├ place_facts ★ 사실. 이 제품의 심장
│ ├ place_faqs FAQ
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
└ sites 발행 사이트 — 사업장당 1개
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
├ site_versions ★ 빌드 버전 — snapshot 박제
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래
```
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
@ -137,6 +138,22 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이
데이터 모양으로 강제된 자리다.
### `place_songs` — 이 숙소의 노래
발행할 때마다 한 곡 만든다. 가사는 소개문과 **같은 재료**(확인된 fact + 조사 근거 + 소개문)로
Gemini 가 쓰고, 곡은 Suno 가 붙인다.
★ **검증 상태(`FactStatus`)가 없다.** 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(`SongStatus`:
`GENERATING` · `READY` · `FAILED`). 스냅샷은 **`READY` 만** 싣는다.
★ **`origin_url`(Suno 가 준 주소)은 발행본에 나가지 않는다.** 만료되는 주소라 그대로 실으면
발행 직후에는 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 `solution/site/songs/<song_id>.mp3`
에 두고, 프리렌더가 사이트 디렉토리로 복사한 것(`/s/<slug>/<song_id>.mp3`)만 나간다.
표에는 추적용으로만 남긴다.
★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `latest_ready` 가 `READY` 중 최신 하나를 고른다.
### `area_contents` + `place_area_refs` — 지역 콘텐츠
★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.

View File

@ -5,6 +5,68 @@
---
## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno)
**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라
새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.
**흐름** — ★ **발행이 노래를 기다린다.**
```
발행 누름 → BUILD 잡
1. 가사(Gemini) → 2. 작곡(Suno, 실측 30~40초 · 상한 5분)
3. mp3 를 out/songs/ 에 보관
4. 스냅샷 → 게이트 → 발행 ← 여기서 비로소 사이트가 나간다
프리렌더가 mp3 를 사이트 디렉토리로 복사
```
**왜 기다리나** — 먼저 굽고 나중에 붙이는 방식으로 먼저 만들어 봤는데, 그러면 발행 직후의
사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다. 사장님이 [사이트 열기] 로 보는 **첫 화면에
그 기능이 빠져 있다.** 값은 발행이 그만큼 늦어지는 것이고, 그건 감수한다.
★ 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면
노래 없이 발행되고 사유가 빌드 로그와 `place_songs.last_error` 에 남는다.
★ 미리보기 빌드(publish=false)에는 만들지 않는다. 유료 호출이라 눌러 보는 것만으로 돈이 나가면 안 된다.
**왜 가사를 우리가 쓰나** — Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 짓는다.
그 가사에는 이 숙소에 없는 것(수영장·조식·오션뷰)이 섞이고 우리는 검증할 방법이 없다 —
사이트의 다른 모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이다.
→ 가사는 **소개문과 같은 재료**(확인된 fact + 조사 근거 + 소개문)로 Gemini 가 쓰고,
Suno 는 곡만 붙인다. 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다
(요금·전화번호를 노래에 넣으면 틀렸을 때 고쳐 부를 수가 없다).
★ 가사에는 `ground_check` 를 걸지 않는다. "밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는
없다 — 문장 단위로 근거를 맞추면 전부 반려된다. 가사는 사실 진술이 아니라 정서다.
**★ Suno 주소를 그대로 싣지 않는다**
Suno 가 주는 audio_url 은 **만료된다.** payload 에 그 주소를 실으면 발행 직후에는 재생되고
몇 주 뒤 조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. mp3 를 받아 보관하고
우리 경로(`/s/<slug>/<song_id>.mp3`)만 발행본에 내보낸다.
**★ 콜백이 아니라 폴링이다**
Suno 는 `callBackUrl` 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다.
이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면
"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다.
(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.)
**경계는 그대로다** — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로
`out/songs/<song_id>.mp3` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다.
프리렌더는 복사하면서 **지난 발행의 mp3 를 치운다** — 발행마다 새 곡이라 안 치우면 1MB 짜리가
발행 횟수만큼 쌓이고, Azure 에도 그대로 올라간다.
**화면** — 헤더의 작은 플레이어(`SongPlayer`). 곡이 없으면 **아무것도 그리지 않는다** —
노래는 발행보다 늦게 도착하므로 그 사이 빈 플레이어를 그리면 고장난 버튼이다.
자동 재생하지 않고(소리가 갑자기 나는 페이지는 닫힌다), 가사를 함께 싣는다
(오디오 안의 말은 크롤러가 못 듣는다).
**표** — `place_songs`. 검증 상태가 없다(창작물이라 "맞는가" 를 물을 대상이 아니다).
상태는 `GENERATING`·`READY`·`FAILED` 셋이고 스냅샷은 READY 만 싣는다. 새 곡이 실패하면
직전 곡이 그대로 남는다. → [DATA_MODEL.md](DATA_MODEL.md)
**검증** — 실제로 발행해 봤다(스테이,머뭄 v15): 가사 '시간이 머무는 고요한 밤'(acoustic
ballad, 154자, $0.0014) → 작곡 40초 → 1.98MB mp3 → **그 다음** 스냅샷(노래 1) → 발행 완료.
`/s/스테이머뭄-99a887f8` 200, mp3 200 `audio/mpeg`, HTML 에 제목·가사·재생 주소 확인.
지난 발행의 곡은 404 로 치워졌다. `tsc --noEmit` · `eslint` · vitest 55건 통과(신규 4건).
---
## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,

View File

@ -167,6 +167,27 @@ CREATE TABLE IF NOT EXISTS public.place_units (
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
-- ★ 곡은 사실이 아니라 만들어진 창작물이라 fact·사진과 같은 검증 상태를 두지 않는다.
-- 대신 완성(READY)된 것만 사이트에 나간다 — 생성 중인 곡을 실으면 재생 버튼이 빈 파일을 가리킨다.
CREATE TABLE IF NOT EXISTS public.place_songs (
song_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
title VARCHAR(200) NOT NULL,
lyrics TEXT NULL, -- 화면에 같이 싣는다. 손님이 무슨 노래인지 읽을 수 있어야 한다
style VARCHAR(200) NULL, -- Suno 에 넘긴 장르·분위기 문자열
provider VARCHAR(40) NOT NULL DEFAULT 'suno',
provider_task_id VARCHAR(120) NULL, -- Suno taskId. 폴링·재조회의 유일한 열쇠
origin_url VARCHAR(1000) NULL, -- ★ Suno 가 준 원본 주소. 만료되므로 이 주소를 사이트에 싣지 않는다
file_name VARCHAR(200) NULL, -- 우리가 받아 둔 파일(solution/site/songs/<이것>). 프리렌더가 사이트로 복사한다
duration_sec NUMERIC(6,2) NULL,
status SMALLINT NOT NULL DEFAULT 1, -- SongStatus: 1=generating 2=ready 3=failed. ★ 2 만 사이트에 나간다
last_error TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS public.place_photos (
media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
@ -362,6 +383,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_place_links_place_url ON public.place_chann
CREATE INDEX IF NOT EXISTS idx_units_place ON public.place_units (place_id);
CREATE INDEX IF NOT EXISTS idx_media_place ON public.place_photos (place_id);
CREATE INDEX IF NOT EXISTS idx_songs_place ON public.place_songs (place_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_media_unit ON public.place_photos (unit_id);
-- place_facts

View File

@ -0,0 +1,30 @@
-- 0010 · 숙소의 노래 표(place_songs)
--
-- 발행할 때마다 이 숙소의 노래를 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
-- 곡은 **사실이 아니라 창작물**이라 fact·사진 같은 검증 상태(FactStatus)를 두지 않는다.
-- 대신 상태는 "만들어졌는가" 하나다: 1=generating · 2=ready · 3=failed.
-- ★ 사이트에는 2 만 나간다. 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다.
--
-- ★ origin_url 을 그대로 싣지 않는 이유: Suno 가 주는 주소는 만료된다. 받아서 우리 쪽에
-- 보관한 파일(file_name)만 사이트에 나간다 — 안 그러면 몇 주 뒤 조용히 재생이 죽는다.
CREATE TABLE IF NOT EXISTS public.place_songs (
song_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
title VARCHAR(200) NOT NULL,
lyrics TEXT NULL,
style VARCHAR(200) NULL,
provider VARCHAR(40) NOT NULL DEFAULT 'suno',
provider_task_id VARCHAR(120) NULL,
origin_url VARCHAR(1000) NULL,
file_name VARCHAR(200) NULL,
duration_sec NUMERIC(6,2) NULL,
status SMALLINT NOT NULL DEFAULT 1,
last_error TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 한 업장의 최신 곡을 꺼내는 경로. 발행본은 늘 "가장 최근에 완성된 한 곡"을 싣는다.
CREATE INDEX IF NOT EXISTS idx_songs_place ON public.place_songs (place_id, created_at DESC);

View File

@ -14,6 +14,7 @@ from common.enums import (
FactStatus,
SourceType,
MediaStatus,
SongStatus,
SiteStatus,
BuildStatus,
JobStatus,
@ -190,6 +191,30 @@ class place_photos(MainTableMixin, MAIN_BASE):
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class place_songs(MainTableMixin, MAIN_BASE):
"""이 숙소의 노래. 발행할 때마다 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus).
★ origin_url(Suno 가 준 주소)은 **사이트에 싣지 않는다.** 만료되는 주소라 그대로 두면
몇 주 뒤 재생만 조용히 죽는다 — 받아서 보관한 file_name 만 발행본으로 나간다."""
__tablename__ = "place_songs"
song_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
title = Column(String(200), nullable=False)
lyrics = Column(Text, nullable=True) # 화면에 함께 싣는다
style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기
provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno")
provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠
origin_url = Column(String(1000), nullable=True) # ★ 만료되는 주소. 보관용 기록일 뿐이다
file_name = Column(String(200), nullable=True) # solution/site/songs/<이것>
duration_sec = Column(Numeric(6, 2), nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value)
last_error = Column(Text, nullable=True)
# ============================================================
# fact : 사실 / FAQ
# ============================================================

View File

@ -283,6 +283,18 @@ class MediaStatus(CodeEnum):
REJECTED = 3
class SongStatus(CodeEnum):
"""place_songs.status 코드값.
★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
"맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다.
★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다."""
GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다)
READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다
FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다)
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
@ -419,6 +431,7 @@ class JobType(CodeEnum):
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
AI_CHECK = 6 # AI 검색 노출 점검
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
class JobStatus(CodeEnum):

View File

@ -131,6 +131,11 @@ class ExternalApiConfig(BaseSettings):
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
# 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). 비면 그 단계만 건너뛴다.
suno_api_key: str = Field("", validation_alias="SUNO_API_KEY")
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.

View File

@ -0,0 +1,39 @@
from sqlalchemy import and_, select, update
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_songs
from common.enums import SongStatus
from common.utils.gtime import GTime
class SongCRUD:
"""place_songs 접근. 발행본이 읽는 것은 `latest_ready` 하나뿐이다."""
async def insert(self, db, row):
return await DB_SESSION_MNG.insert(db, row)
async def update(self, db, song_id, data: dict):
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(place_songs)
.where(place_songs.song_id == song_id, place_songs.deleted == False) # noqa: E712
.values(**data, updated_at=GTime.UTC()),
)
async def latest_ready(self, db, place_id):
"""이 업장의 **가장 최근에 완성된** 곡 하나.
★ READY 만 본다. 발행마다 새 곡을 만들므로 GENERATING 행이 함께 있을 수 있는데,
그걸 집으면 아직 없는 파일을 사이트가 가리킨다. 실패(FAILED)도 마찬가지다 —
새 곡이 실패하면 사이트는 **직전 곡을 그대로 유지**한다(빈 플레이어보다 낫다)."""
return await DB_SESSION_MNG.execute(
db,
select(place_songs)
.where(and_(
place_songs.place_id == place_id,
place_songs.status == SongStatus.READY.value,
place_songs.deleted == False, # noqa: E712
))
.order_by(place_songs.created_at.desc())
.limit(1),
)

View File

@ -33,6 +33,9 @@ def _content_type(path: Path) -> str:
".json": "application/json; charset=utf-8",
".xml": "application/xml; charset=utf-8",
".txt": "text/plain; charset=utf-8",
# 노래. guess_type 도 audio/mpeg 를 주지만 플랫폼마다 갈려서 못 박아 둔다 —
# octet-stream 으로 올라가면 브라우저가 재생 대신 내려받기로 처리한다.
".mp3": "audio/mpeg",
}
return overrides.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
@ -46,6 +49,10 @@ def _cache_control(path: Path) -> str:
# 60초로 두면 방문자가 수 MB 짜리 폰트를 계속 다시 받는다.
if head == "fonts":
return "public, max-age=604800"
# 노래 파일명은 song_id(UUID)다 — 곡이 바뀌면 이름도 바뀌므로 영구 캐시가 안전하다.
# 1MB 남짓한 파일을 60초마다 다시 받게 두면 헤더 버튼 하나가 트래픽을 먹는다.
if path.suffix.lower() == ".mp3":
return "public, max-age=31536000, immutable"
# HTML · robots.txt · sitemap.xml — 발행하면 곧바로 반영되어야 한다.
return "public, max-age=60, must-revalidate"

View File

@ -29,7 +29,7 @@ from common.logger import LOG
from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail, song_service
from services.local_content_service import LocalContentService
from services.site_payload import emit_payload
from services.snapshot import build_snapshot
@ -126,6 +126,23 @@ async def run_build(job: dict) -> dict:
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
# ★ 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다 (2026-09-11 결정).
# 순서가 뒤집히면(먼저 굽고 나중에 붙이기) 발행 직후의 사이트에는 노래가 없고 몇 분 뒤
# 조용히 생긴다 — 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
# 값은 발행이 30초~3분 늦어지는 것이고(Suno 폴링 상한 5분), 그건 감수한다.
# ★ 실패해도 빌드는 계속한다. 주변 정보와 같은 규칙이다 — 곁들이 하나가 사장님 사이트
# 발행을 막을 이유가 없다. 노래 없이 나가고, 사유는 아래 로그와 place_songs 에 남는다.
# ★ 미리보기 빌드(publish=False)에는 만들지 않는다 — 유료 호출이라 눌러 보는 것만으로
# 비용이 나가면 안 된다.
song_result: dict | None = None
if want_publish:
try:
song_result = await song_service.ensure_song(place_id, owner_user_id)
LOG.i(f"[build] place={place_id} 노래 — {song_result}")
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
song_result = {"error": f"{type(ex).__name__}: {ex}"}
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}")
snapshot = await build_snapshot(place)
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
@ -148,6 +165,9 @@ async def run_build(job: dict) -> dict:
result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no,
"site_version_id": str(version.site_version_id)}
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
if song_result is not None:
result["song"] = song_result
now = GTime.UTC()
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):

View File

@ -194,3 +194,77 @@ async def generate_copy(
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
)
return result
@dataclass
class GeneratedSong:
"""가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다."""
title: str
lyrics: str
style: str
async def generate_song(
place_name: str,
category: PlaceCategory,
*,
region: str,
grounding: list[str],
intro: str = "",
model: str = DEFAULT_TEXT_MODEL,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> GeneratedSong:
"""이 업소의 노래 가사를 쓴다.
★ `ground_check` 를 걸지 않는다. 가사는 사실 진술이 아니라 정서라 문장 단위로 근거를
맞추면 전부 반려된다("밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는 없다).
대신 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다(services/prompts/song 머리주석).
★ 재료가 하나도 없으면 부르지 않는다 — 소개문과 같은 규칙이다. 상호와 지역만으로 쓴 노래는
어느 숙소에 붙여도 말이 되는 노래이고, 그건 이 기능이 하려던 일이 아니다.
"""
if not is_configured():
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
if not grounding and not (intro or "").strip():
raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다")
from common.category_schema import get_schema
from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt
body = {
"contents": [{"role": "user", "parts": [{
"text": build_song_prompt(place_name, get_schema(category).label, region, grounding, intro)
}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": SONG_SCHEMA,
# 소개문(0.2)보다 높다 — 노래는 정확해야 하는 글이 아니라 흥얼거릴 글이다.
"temperature": 0.9,
},
}
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try:
payload = await call(client, model, body, max_retries)
parsed = json.loads(extract_text(payload))
except json.JSONDecodeError as ex:
raise GeminiInvalidOutput(f"가사 파싱 실패: {ex}") from ex
finally:
if owns_client:
await client.aclose()
title = (parsed.get("title") or "").strip()
lyrics = (parsed.get("lyrics") or "").strip()
style = (parsed.get("style") or "").strip()
if not lyrics:
raise GeminiInvalidOutput("가사가 비어 있다")
usage = read_usage(payload)
LOG.i(
f"[gemini-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
)
# 제목이 비면 상호를 쓴다 — 빈 제목은 플레이어에서 빈 줄로 보인다.
return GeneratedSong(title=title or place_name, lyrics=lyrics, style=style or "acoustic ballad")

View File

@ -0,0 +1,158 @@
"""Suno API — 가사를 받아 40초짜리 곡 한 편을 만든다.
API 문서 https://docs.sunoapi.org
가사 services/external/gemini_text.generate_song (여기는 작곡만 한다)
쓰는 곳 services/song_service (잡 흐름·저장)
★ **콜백을 쓰지 않고 폴링한다.**
Suno 는 완료 시 `callBackUrl` 로 POST 를 보내 주는데, 그러려면 Suno 쪽에서 우리 백엔드에
닿아야 한다. 이 서버는 로컬(:9800)이거나 사내망(킹서버)이라 그런 주소가 없다 —
콜백을 믿게 만들어 두면 "요청은 성공했는데 결과가 영영 안 옴" 이 되고, 그건 화면상
아무 일도 안 일어나는 종류의 실패다. 그래서 `generate` 로 taskId 를 받고 `record-info` 를
직접 물어본다. 잡 워커에서 도는 코드라 몇 분 기다리는 것이 문제가 되지 않는다.
(`callBackUrl` 은 API 가 필수로 요구해서 값만 채워 보낸다. 우리는 그 주소를 듣지 않는다.)
★ **한 요청에 곡이 두 편 온다.** Suno 는 같은 가사로 변주 두 개를 만들어 준다(sunoData 배열).
우리는 **첫 번째 한 곡만** 쓴다 — 사장님에게 고르라고 묻는 화면이 없고, 두 곡을 다 실으면
손님이 무엇을 듣는지 우리도 모른다.
★ **오디오 주소는 만료된다.** 여기서 돌려주는 `audio_url` 을 그대로 사이트에 싣지 않는다.
받는 쪽(song_service)이 파일을 내려받아 우리 쪽에 보관한다.
"""
import asyncio
from typing import Any, Optional
import httpx
from common.logger import LOG
from config.server_configs import external_api_config
BASE_URL = "https://api.sunoapi.org/api/v1"
# 실측(참고 프로젝트 o2o-castad-backend): 스트림 주소는 30~40초, 내려받을 수 있는 주소는 2~3분.
# 우리는 파일을 받아야 하므로 뒤쪽 기준으로 기다린다.
POLL_INTERVAL_SEC = 10
POLL_TIMEOUT_SEC = 300
REQUEST_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
# 40초짜리를 만든다. 헤더의 작은 플레이어에서 듣는 곡이라 길 이유가 없고,
# 길수록 생성 시간과 요금이 같이 는다.
SONG_SECONDS = 40
MODEL = "V5"
class SunoNotConfigured(RuntimeError):
"""SUNO_API_KEY 가 없다 — 노래만 건너뛰고 발행은 계속한다."""
class SunoError(RuntimeError):
"""호출 실패·거절. 잡의 last_error 로 남는다."""
def is_configured() -> bool:
return bool((external_api_config.suno_api_key or "").strip())
def _headers() -> dict:
return {
"Authorization": f"Bearer {external_api_config.suno_api_key}",
"Content-Type": "application/json",
}
async def generate(lyrics: str, *, title: str, style: str, client: httpx.AsyncClient) -> str:
"""작곡 요청. taskId 를 돌려준다.
★ `customMode=True` 다 — prompt 를 '주제' 가 아니라 **가사 그대로** 쓰라는 뜻이다.
false 로 두면 Suno 가 가사를 자기가 새로 쓴다. 우리는 이 숙소의 사실로 쓴 가사를
넘기는 것이므로, 그걸 버리면 이 기능의 의미가 없다.
"""
if not is_configured():
raise SunoNotConfigured("SUNO_API_KEY 미설정")
body = {
"model": MODEL,
"customMode": True,
"instrumental": False,
# 길이는 API 파라미터가 아니라 프롬프트로 지시한다(참고 프로젝트와 같은 방식).
"prompt": f"[Song Duration: Around {SONG_SECONDS} seconds]\n{lyrics}",
"title": title[:80],
"style": style,
# 듣지 않는 주소다(머리주석). 비워서 보내면 거절당한다.
"callBackUrl": external_api_config.suno_callback_url or "https://example.com/api/suno/callback",
}
try:
res = await client.post(f"{BASE_URL}/generate", headers=_headers(), json=body, timeout=REQUEST_TIMEOUT)
except httpx.HTTPError as ex:
raise SunoError(f"generate 호출 실패: {type(ex).__name__}: {ex}") from ex
if res.status_code != 200:
raise SunoError(f"generate HTTP {res.status_code}: {res.text[:300]}")
data = res.json() or {}
if data.get("code") != 200:
raise SunoError(f"generate 거절: {data.get('msg')}")
task_id = ((data.get("data") or {}).get("taskId"))
if not task_id:
raise SunoError(f"generate 응답에 taskId 가 없다: {str(data)[:300]}")
return task_id
def _first_clip(payload: dict) -> Optional[dict]:
"""완성된 클립 하나. 아직이면 None.
★ 상태 문자열을 믿기 전에 **주소가 실제로 있는지** 본다. SUCCESS 인데 audioUrl 이
아직 비어 오는 응답을 참고 프로젝트가 겪었다(스트림만 먼저 나오는 구간).
"""
data = (payload or {}).get("data") or {}
status = (data.get("status") or "").upper()
if status in {"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR"}:
raise SunoError(f"작곡 실패: {status} {data.get('errorMessage') or ''}".strip())
clips = ((data.get("response") or {}).get("sunoData")) or []
for clip in clips:
if clip.get("audioUrl") or clip.get("sourceAudioUrl"):
return clip
return None
async def wait_for_clip(task_id: str, *, client: httpx.AsyncClient) -> dict[str, Any]:
"""완성될 때까지 물어본다. 돌려주는 것은 첫 클립 하나.
★ 상한(POLL_TIMEOUT_SEC)을 둔다. Suno 가 영영 안 끝내는 경우 잡이 그대로 매달리면
워커 한 자리를 계속 차지한다 — 노래 하나 때문에 다른 사업장의 수집이 멈춘다.
"""
waited = 0
while waited < POLL_TIMEOUT_SEC:
await asyncio.sleep(POLL_INTERVAL_SEC)
waited += POLL_INTERVAL_SEC
try:
res = await client.get(
f"{BASE_URL}/generate/record-info",
headers=_headers(),
params={"taskId": task_id},
timeout=REQUEST_TIMEOUT,
)
res.raise_for_status()
except httpx.HTTPError as ex:
# 폴링 한 번 실패는 실패가 아니다 — 다음 차례에 다시 묻는다.
LOG.w(f"[suno] 상태 조회 실패(계속 기다린다) task={task_id}: {type(ex).__name__}")
continue
clip = _first_clip(res.json() or {})
if clip:
LOG.i(f"[suno] 작곡 완료 task={task_id} ({waited}초)")
return clip
raise SunoError(f"{POLL_TIMEOUT_SEC}초 안에 완성되지 않았다 task={task_id}")
async def download(url: str, *, client: httpx.AsyncClient) -> bytes:
"""오디오 파일을 받아 온다. 보관은 부르는 쪽이 한다."""
res = await client.get(url, timeout=httpx.Timeout(180.0, connect=10.0), follow_redirects=True)
res.raise_for_status()
return res.content

View File

@ -0,0 +1,62 @@
"""이 숙소의 노래 — 가사 프롬프트와 응답 스키마.
★ 왜 가사를 **우리가** 쓰고 Suno 에는 작곡만 시키나
Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 지어낸다. 그 가사에는 이 숙소에
없는 것(수영장·조식·오션뷰)이 섞이고, 우리는 그걸 검증할 방법이 없다 — 사이트의 다른
모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이 된다.
그래서 가사는 소개문과 **같은 재료**(확인된 fact + 소개문)로 여기서 쓰고, Suno 는 그 가사에
곡을 붙이기만 한다.
★ 그래도 가사는 사실 진술이 아니다
"밤이 깊어도 불이 켜져 있다" 같은 줄은 fact 가 아니라 분위기다. 그래서 `ground_check` 를
걸지 않는다 — 대신 프롬프트가 **없는 시설·없는 숫자를 말하지 말라**고 못 박는다.
요금·전화번호·주소를 가사에 넣지 않는 것도 같은 이유다(틀리면 예약 클레임이고, 노래는
고쳐 부르기도 어렵다).
"""
# Suno 가 받는 style 문자열. 장르를 모델이 고르게 두되 후보를 좁힌다 —
# 열어 두면 숙소 사이트에 어울리지 않는 것(하드록·트랩)이 나온다.
STYLE_CHOICES = [
"acoustic ballad", "city pop", "folk pop", "lo-fi", "bossa nova", "soft rock", "jazz",
]
RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "곡 제목. 상호를 그대로 쓰지 말고 한 구절로."},
"lyrics": {"type": "string", "description": "가사. [Verse]/[Chorus] 구조 태그를 포함한다."},
"style": {"type": "string", "description": f"장르·분위기. 다음 중 하나에서 시작한다: {', '.join(STYLE_CHOICES)}"},
},
"required": ["title", "lyrics", "style"],
}
def build_prompt(place_name: str, category_label: str, region: str, grounding: list[str], intro: str) -> str:
"""가사 프롬프트. 재료는 소개문 생성과 같은 것을 받는다."""
material = "\n".join(f"- {line}" for line in grounding) or "- (확인된 항목 없음)"
intro_block = f"\n[이 숙소 소개문]\n{intro.strip()}\n" if (intro or "").strip() else ""
return f"""너는 작은 가게의 노래를 쓰는 작사가다. 아래 업소의 노래 가사를 쓴다.
[업소]
- 상호: {place_name}
- 업종: {category_label}
- 지역: {region or "(모름)"}
[확인된 항목 — 이 안에서만 말한다]
{material}
{intro_block}
[쓰는 법]
- 한국어. 40초 안에 불리는 길이다 — [Verse] 한 덩이 + [Chorus] 한 덩이면 충분하다.
- 구조 태그([Verse], [Chorus])를 반드시 넣는다. Suno 가 이 태그로 곡을 나눈다.
- **없는 것을 말하지 않는다.** 위 목록에 없는 시설·풍경·서비스를 지어내지 않는다.
지역과 계절, 머무는 마음처럼 사실 확인이 필요 없는 정서는 자유롭게 쓴다.
- **숫자를 넣지 않는다.** 요금·전화번호·주소·객실 수는 가사에 쓰지 않는다.
틀리면 손님이 손해를 보고, 노래는 고쳐 부르기 어렵다.
- 광고 문구처럼 쓰지 않는다("최고", "1등", "예약하세요"). 손님이 흥얼거릴 노래다.
- 상호는 후렴에 한 번쯤 자연스럽게 넣는다.
[출력]
title(곡 제목) · lyrics(가사) · style(장르·분위기) 를 JSON 으로 준다.
style 은 이 업소의 분위기에 맞는 것을 고른다: {', '.join(STYLE_CHOICES)}.
"""

View File

@ -955,6 +955,25 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
# ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석).
# 렌더러는 비면 해당 섹션을 그리지 않는다.
"routes": [],
# ★ 이 숙소의 노래. `audioUrl` 은 **우리 쪽 경로**다 — Suno 가 준 주소는 만료되므로
# 파일을 받아 두고(song_service) 프리렌더가 사이트 디렉토리로 복사한 것을 가리킨다.
# 경로를 여기서 만드는 이유: 발행본의 주소 규칙(basePath + /s/<slug>)을 아는 곳이 여기다.
"songs": [
{
"songId": row["song_id"],
"title": row["title"],
"lyrics": row.get("lyrics") or None,
"style": row.get("style") or None,
"durationSec": row.get("duration_sec"),
# 프리렌더가 복사해 놓을 자리. 파일명은 그대로 쓴다.
# basePath 자체가 이미 `/s/<slug>`(또는 서브패스 마운트라면 그 앞에 접두어)다.
"audioUrl": f"{target['basePath']}/{row['file_name']}",
# 프리렌더가 원본을 찾을 때 쓰는 이름(솔루션 밖으로는 안 나간다).
"fileName": row["file_name"],
}
for row in (snapshot.get("songs") or [])
if (row.get("file_name") or "").strip()
],
"narrative": narrative,
"theme": theme,
}

View File

@ -560,6 +560,11 @@ class SiteService:
if job_id is None:
res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING)
return res
# ★ 노래는 여기서 따로 걸지 않는다. BUILD 잡이 스냅샷을 뜨기 **전에** 직접 만든다
# (`build_service.run_build` → `song_service.ensure_song`) — 발행이 노래를 기다린다.
# 따로 걸면 먼저 구워지고 노래가 몇 분 뒤 붙는데, 그러면 사장님이 [사이트 열기] 로
# 보는 첫 화면에 그 기능이 빠져 있다(2026-09-11 결정).
res.job_id = uuid.UUID(job_id)
res.status = JobStatus.PENDING
res.created = created

View File

@ -23,7 +23,7 @@ from sqlalchemy import or_, select
from common.category_schema import get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import (
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_units,
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_songs, place_units,
site_sections, sites,
)
from common.enums import (
@ -35,6 +35,7 @@ from common.enums import (
LocalSource,
MediaStatus,
PlaceCategory,
SongStatus,
)
from common.logger import LOG
from services.external.naver import region_key
@ -89,6 +90,16 @@ async def build_snapshot(place) -> dict:
).order_by(place_photos.sort_order.asc())
)
# ★ 완성(READY)된 최신 곡 하나. 발행마다 새 곡을 만들므로 생성 중인 행이 함께 있을 수 있는데,
# 그걸 실으면 사이트가 아직 없는 파일을 가리킨다. 새 곡이 실패하면 직전 곡이 그대로 남는다.
song_rows = await _select(
select(place_songs).where(
place_songs.place_id == pid,
place_songs.deleted == False, # noqa: E712
place_songs.status == SongStatus.READY.value,
).order_by(place_songs.created_at.desc()).limit(1)
)
local_rows = await _local_contents(place)
snapshot = {
@ -161,11 +172,26 @@ async def build_snapshot(place) -> dict:
# ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 —
# fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가').
"local": local_rows,
# ★ 이 숙소의 노래. 파일은 DB 가 아니라 `solution/site/songs/<file_name>` 에 있고,
# 프리렌더가 그걸 사이트 디렉토리로 복사한다(services/song_service 머리주석).
# ★ origin_url(Suno 주소)은 싣지 않는다 — 만료되는 주소라 발행본에 나가면 안 된다.
"songs": [
{
"song_id": str(r.song_id),
"title": r.title,
"lyrics": r.lyrics,
"style": r.style,
"file_name": r.file_name,
"duration_sec": float(r.duration_sec) if r.duration_sec is not None else None,
}
for r in song_rows
if (r.file_name or "").strip()
],
}
LOG.i(
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
f"지역 {len(snapshot['local']['contents'])}"
f"지역 {len(snapshot['local']['contents'])} · 노래 {len(snapshot['songs'])}"
)
return snapshot

View File

@ -0,0 +1,258 @@
"""이 숙소의 노래 — SONG 잡이 하는 일.
가사 services/external/gemini_text.generate_song (확인된 fact + 소개문으로 쓴다)
작곡 services/external/suno (폴링 · 40초 · 한 곡)
여기 재료 모으기 → 가사 → 작곡 → 파일 보관 (발행이 이걸 기다린다)
★ **발행은 노래를 기다린다** (2026-09-11 결정).
BUILD 잡이 스냅샷을 뜨기 **전에** 여기를 부른다(`build_service.run_build`). 그래서 발행된
사이트에는 처음부터 노래가 들어 있다 — 사장님이 [사이트 열기] 를 눌러 본 화면과 손님이
보는 화면이 같다.
값은 발행이 그만큼 늦어지는 것이다(실측 30초~3분, 상한 5분). 먼저 굽고 나중에 붙이는
방식도 만들어 봤지만, 그러면 발행 직후의 사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다 —
"발행했는데 그 기능이 없다" 를 사장님이 먼저 본다.
★ 잡 타입(JobType.SONG)은 그대로 둔다.
발행과 무관하게 **곡만 다시 만들** 때 쓰는 길이다(운영자가 잡을 직접 넣는다).
발행 경로와 같은 함수(`ensure_song`)를 부르므로 둘이 갈라지지 않는다.
★ 왜 파일을 받아서 보관하나
Suno 가 주는 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후에는 재생되고 몇 주 뒤
조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. 그래서 mp3 를 받아
`solution/site/songs/` 에 두고, 프리렌더가 사이트 디렉토리로 복사한다.
(백엔드는 여전히 HTML 을 만들지 않는다 — 파일과 payload 를 같은 약속된 자리에 둘 뿐이다.)
★ Blob Storage 로는 **발행이 올린다** — 여기서 따로 올리지 않는다.
파일이 `out/s/<slug>/` 안에 있으므로 `azure_static.publish(slug)` 가 사이트를 통째로
올릴 때 함께 올라간다(content-type `audio/mpeg`, 해시 파일명이라 immutable 캐시).
지난 곡은 `_remove_stale_site_files` 가 블롭에서도 지운다.
→ 업로더를 하나 더 두면 같은 컨테이너에 **두 규칙**이 생긴다(경로·캐시·정리 주체).
참고 프로젝트(o2o-castad-backend)는 사이트가 없어 직접 올릴 수밖에 없었지만,
여기서는 노래가 사이트의 일부라 사이트와 같은 길로 나가는 것이 맞다.
덕분에 주소도 같은 오리진(`/s/<slug>/<song_id>.mp3`)이라 CORS·혼합콘텐츠 문제가 없다.
★ 실패는 발행을 막지 않는다
키가 없거나(SUNO_API_KEY·GEMINI_API_KEY), 재료가 없거나, Suno 가 늦으면(상한 5분)
**노래만 없이** 발행된다. 기다리는 것과 막는 것은 다르다 — 음악 API 가 느린 날
사장님 사이트가 아예 안 나가는 것은 맞바꿀 수 없는 손해다.
사유는 place_songs.last_error 와 빌드 로그에 남는다.
"""
import uuid
from pathlib import Path
import httpx
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_channels, place_facts, place_songs, places
from common.enums import DBWRType, ErrorType, PlaceCategory, SongStatus
from common.logger import LOG
from common.utils.gtime import GTime
from crud.fact_crud import FactCRUD
from crud.place_crud import PlaceCRUD
from crud.song_crud import SongCRUD
from services import place_research, site_payload
from services.external import gemini_text, suno
from services.llm.gemini import GeminiError, GeminiInvalidOutput, GeminiNotConfigured
_fact_crud = FactCRUD()
_place_crud = PlaceCRUD()
_song_crud = SongCRUD()
# payload 와 나란히 두는 자리. 프리렌더가 이 디렉토리에서 파일을 찾는다.
# payload_dir 이 `/app/out/payloads` 면 여기는 `/app/out/songs` 다 — 컴포즈가 둘 다 호스트의
# `solution/site/` 아래로 붙인다. 한 디렉토리 약속(ARCHITECTURE 1절)을 노래에도 그대로 쓴다.
SONGS_DIRNAME = "songs"
class SongAborted(RuntimeError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다."""
def songs_dir() -> Path:
return site_payload.payload_dir().parent / SONGS_DIRNAME
async def _grounding(place, place_id: str) -> tuple[list[str], str]:
"""가사 재료 — 확인된 fact + 수집/조사 원문, 그리고 소개문.
★ 소개문 생성과 **같은 재료**를 쓴다(copy_service). 노래만 다른 출처를 보면 사이트의
글과 노래가 서로 다른 숙소를 말하게 된다.
"""
schema = get_schema(PlaceCategory(place.category))
pid = uuid.UUID(place_id)
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
)
if f_err != ErrorType.SUCCESS:
raise SongAborted(f"fact 조회 실패: {f_err.name}")
lines: list[str] = []
intro = ""
for row in (fact_rows or []):
value = (row.value or "").strip()
if not value:
continue
if row.key == "intro":
intro = value
continue
if row.key == "meta_description":
continue
spec = schema.get(row.key)
label = spec.label if spec else row.key
lines.append(f"{label}: {value}{(' ' + row.unit) if row.unit else ''}")
# 조사 근거(남이 쓴 글)도 재료다 — 이 숙소의 내력이 거기에만 있는 경우가 많다.
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_links(s, pid, False),
)
if l_err == ErrorType.SUCCESS:
for link in (link_rows or []):
raw = link.raw if isinstance(link.raw, dict) else {}
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
continue
text = (raw.get("text") or "").strip()
if text:
lines.append(text[:1500])
return lines, intro
async def run_song(job: dict) -> dict:
"""SONG 잡 핸들러 — 발행과 무관하게 곡만 다시 만들 때 쓴다.
발행 경로는 이 잡을 거치지 않고 `ensure_song` 을 직접 부른다(build_service)."""
payload = job["payload"]
return await ensure_song(payload["place_id"], payload["owner_user_id"])
async def ensure_song(place_id: str, owner_user_id: str) -> dict:
"""이 업장의 노래를 한 곡 만든다. 돌려주는 dict 가 곧 잡 결과이자 빌드 로그다.
★ 실패를 예외로 올리지 않는다(사업장을 못 찾는 것 같은 진짜 고장만 예외다).
발행이 이 함수를 기다리는데 여기서 예외가 나면 **노래 때문에 발행이 통째로 실패**한다.
음악 API 가 느린 날 사장님 사이트가 안 나가는 것은 맞바꿀 수 없는 손해다."""
if not suno.is_configured():
# 키가 없는 것은 고장이 아니라 설정이다 — 예외로 올려 재시도·DEAD 로 만들지 않는다.
LOG.i(f"[song] place={place_id} 건너뜀 — SUNO_API_KEY 미설정")
return {"place_id": place_id, "skipped": "SUNO_API_KEY 미설정"}
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise SongAborted(f"사업장을 찾을 수 없다: {place_id}")
try:
get_schema(PlaceCategory(place.category))
except (CategorySchemaError, ValueError) as ex:
raise SongAborted(f"지원하지 않는 업종: {place.category}") from ex
lines, intro = await _grounding(place, place_id)
region = site_payload.region_label(place.road_address, place.address) or ""
# ── 1. 가사 ───────────────────────────────────────────────
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client:
try:
song = await gemini_text.generate_song(
place.name, PlaceCategory(place.category),
region=region, grounding=lines, intro=intro, client=client,
)
except GeminiNotConfigured:
LOG.i(f"[song] place={place_id} 건너뜀 — GEMINI_API_KEY 미설정")
return {"place_id": place_id, "skipped": "GEMINI_API_KEY 미설정"}
except GeminiInvalidOutput as ex:
LOG.i(f"[song] place={place_id} 건너뜀 — {ex}")
return {"place_id": place_id, "skipped": str(ex)}
except GeminiError as ex:
raise SongAborted(f"가사 생성 실패: {ex}") from ex
# 가사를 먼저 적재한다 — 작곡이 실패해도 "무엇을 만들려 했는가" 가 남아야 한다.
row = place_songs(
song_id=uuid.uuid4(),
place_id=uuid.UUID(place_id),
title=song.title[:200],
lyrics=song.lyrics,
style=song.style[:200],
status=SongStatus.GENERATING.value,
)
add_err = await DB_SESSION_MNG.execute_lambda_run(
[place_songs.DBType()], [lambda s: _song_crud.insert(s, row)],
)
if add_err != ErrorType.SUCCESS:
raise SongAborted(f"노래 행 생성 실패: {add_err.name}")
async def _fail(reason: str) -> dict:
await DB_SESSION_MNG.execute_lambda_claim(
place_songs.DBType(),
lambda s: _song_crud.update(s, row.song_id, {
"status": SongStatus.FAILED.value, "last_error": reason[:2000],
}),
)
LOG.w(f"[song] place={place_id} 실패: {reason}")
return {"place_id": place_id, "song_id": str(row.song_id), "error": reason}
# ── 2. 작곡 ───────────────────────────────────────────
try:
task_id = await suno.generate(song.lyrics, title=song.title, style=song.style, client=client)
except suno.SunoNotConfigured as ex:
return await _fail(str(ex))
except suno.SunoError as ex:
return await _fail(f"작곡 요청 실패: {ex}")
await DB_SESSION_MNG.execute_lambda_claim(
place_songs.DBType(),
lambda s: _song_crud.update(s, row.song_id, {"provider_task_id": task_id}),
)
try:
clip = await suno.wait_for_clip(task_id, client=client)
except suno.SunoError as ex:
return await _fail(str(ex))
audio_url = clip.get("audioUrl") or clip.get("sourceAudioUrl") or ""
try:
content = await suno.download(audio_url, client=client)
except httpx.HTTPError as ex:
return await _fail(f"오디오 내려받기 실패: {type(ex).__name__}: {ex}")
# ── 3. 보관 ───────────────────────────────────────────────
# 파일명은 song_id 다 — 발행마다 새 곡이 생기므로 이름이 겹치면 옛 곡이 새 곡으로 바뀐다.
file_name = f"{row.song_id}.mp3"
directory = songs_dir()
try:
directory.mkdir(parents=True, exist_ok=True)
tmp = directory / f".{file_name}.tmp"
tmp.write_bytes(content)
# payload 와 같은 이유로 rename 이다 — 프리렌더가 반쯤 쓰인 파일을 집으면 안 된다.
tmp.replace(directory / file_name)
except OSError as ex:
return await _fail(f"파일 저장 실패: {ex}")
duration = clip.get("duration")
await DB_SESSION_MNG.execute_lambda_claim(
place_songs.DBType(),
lambda s: _song_crud.update(s, row.song_id, {
"status": SongStatus.READY.value,
"file_name": file_name,
"origin_url": audio_url[:1000] or None,
"duration_sec": round(float(duration), 2) if isinstance(duration, (int, float)) else None,
"title": (clip.get("title") or song.title)[:200],
}),
)
LOG.i(f"[song] place={place_id} '{song.title}' ({song.style}) 완성 — {len(content)} bytes · {file_name}")
# ★ 여기서 재빌드를 걸지 않는다. 발행이 이 함수를 **기다리고 있고**, 돌아가면 바로 그
# 스냅샷에 이 곡이 실린다. 잡으로 따로 돌 때(운영자 재생성)도 같다 — 다음 발행에 실린다.
return {
"place_id": place_id, "song_id": str(row.song_id), "title": song.title,
"style": song.style, "file": file_name,
}

View File

@ -15,6 +15,7 @@
JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거)
JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트
JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회)
JobType.SONG ✓ services/song_service.run_song — 이 숙소의 노래 한 곡(가사 Gemini → 작곡 Suno)
JobType.AI_CHECK → reports 모듈이 붙을 때
"""
@ -80,6 +81,7 @@ def _register_builtin():
from services.build_service import run_build
from services.copy_service import run_copy
from services.story_service import run_local_sync
from services.song_service import run_song
from services.vision_service import run_vision
if JobType.COLLECT.value not in HANDLERS:
@ -92,6 +94,8 @@ def _register_builtin():
HANDLERS[JobType.BUILD.value] = run_build
if JobType.LOCAL_SYNC.value not in HANDLERS:
HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync
if JobType.SONG.value not in HANDLERS:
HANDLERS[JobType.SONG.value] = run_song
_register_builtin()

View File

@ -118,5 +118,7 @@ export function sanitizePayloadForPublish(payload: SitePayload): SitePayload {
links: payload.links.filter((link) => link.confirmed),
// 대체 텍스트 없는 이미지는 어차피 렌더하지 않는다 — 목록에서도 뺀다.
media: payload.media.filter((item) => item.alt?.trim()),
// 재생 주소가 없는 곡은 버튼만 있고 소리가 없다. 목록에서 뺀다.
songs: (payload.songs ?? []).filter((song) => song.audioUrl?.trim()),
};
}

View File

@ -41,6 +41,15 @@ export interface SitePayload {
/** 주요 거점까지의 이동 시간. */
routes: RouteEntry[];
/**
* 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
*
* ★ 발행이 노래를 기다리므로 발행본에는 보통 한 곡이 실려 있다. 그래도 빌 수 있다 —
* 키가 없거나(SUNO_API_KEY) 작곡이 실패하면 **노래 없이** 발행한다(발행을 막지는 않는다).
* 렌더러는 비면 플레이어를 아예 그리지 않는다.
*/
songs: SongTrack[];
/** LLM 이 쓴 문장(allow_llm=true 필드). 사실이 아니라 문장이라 fact 와 분리한다. */
narrative: Narrative;
@ -323,6 +332,25 @@ export interface Narrative {
summary?: string;
}
export interface SongTrack {
songId: string;
title: string;
/** 가사. 화면에서 펼쳐 볼 수 있게 함께 싣는다 — 무슨 노래인지 읽히지 않으면 아무도 안 누른다. */
lyrics?: string | null;
/** 장르·분위기(Suno 에 넘긴 값). */
style?: string | null;
durationSec?: number | null;
/**
* 재생 주소. **우리 쪽 경로**다(`/s/<slug>/<파일>`).
*
* ★ Suno 가 준 주소를 그대로 쓰지 않는다 — 만료되는 주소라, 발행 직후에는 재생되고
* 몇 주 뒤 조용히 죽는다. 백엔드가 파일을 받아 두고 프리렌더가 사이트로 복사한다.
*/
audioUrl: string;
/** 프리렌더가 원본을 찾을 때 쓰는 파일명. 렌더러는 쓰지 않는다. */
fileName?: string;
}
export interface SiteTheme {
templateId: string;
colors: {

View File

@ -76,6 +76,8 @@ const SITE_DIR = 's';
const HERE = dirname(fileURLToPath(import.meta.url));
const SITE_ROOT = resolve(HERE, '..', '..'); // dist/prerender → site/
// 백엔드가 노래 파일을 떨구는 자리. payload 디렉토리와 나란히 둔다(한 디렉토리 약속).
const SONGS_DIR = join(SITE_ROOT, 'songs');
const CLIENT_DIR = join(SITE_ROOT, 'dist', 'client');
interface Args {
@ -285,6 +287,53 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) {
};
}
/**
* 이 숙소의 노래 파일을 사이트 디렉토리로 옮겨 놓는다.
*
* ★ 왜 백엔드가 직접 out/ 에 쓰지 않나
* 백엔드는 발행물 디렉토리를 모른다 — payload JSON 을 약속된 자리에 떨구는 것이 경계다
* (ARCHITECTURE 1절). 노래도 같은 약속을 쓴다: 백엔드는 `site/songs/<파일>` 에 두고,
* 굽는 쪽인 여기가 사이트 안으로 복사한다. 그래야 Azure 발행(`azure_static.publish`)이
* 사이트 디렉토리를 통째로 올릴 때 노래도 함께 올라간다.
*
* ★ 없으면 조용히 넘어간다. 곡은 발행보다 2~3분 늦게 완성되므로 "아직 없음" 이 정상이고,
* 그때 payload 에 songs 가 비어 있어 화면도 플레이어를 안 그린다.
*/
function copySongs(payload: SitePayload, siteDir: string) {
const wanted = new Set<string>();
for (const song of payload.songs ?? []) {
const name = song.fileName || song.audioUrl.split('/').pop();
if (!name) continue;
wanted.add(name);
const from = join(SONGS_DIR, name);
if (!existsSync(from)) {
console.warn(` ! 노래 파일이 없습니다(건너뜀): ${from}`);
continue;
}
const to = join(siteDir, name);
rmSync(to, {force: true});
copyFileSync(from, to);
}
/*
* 지난 발행의 곡은 치운다.
*
* ★ 발행할 때마다 새 곡을 만들고 파일명은 song_id 라, 치우지 않으면 발행 횟수만큼 1MB 짜리
* 파일이 사이트 디렉토리에 쌓인다. 그리고 그것들은 Azure 발행 때 **함께 올라간다** —
* 아무도 듣지 않는 옛 곡이 계속 쌓이는 종류의 낭비다.
* ★ 자산(out/assets)과 달리 보관 기간을 두지 않는다. 번들은 크롤러가 나중에 렌더할 때
* 필요하지만(AGENTS.md), 노래는 그 페이지에서 버튼을 눌러야 나는 것이라 옛 HTML 이
* 가리킬 일이 없다 — payload 에 실린 곡 하나만 남기면 된다.
*/
if (!existsSync(siteDir)) return;
for (const entry of readdirSync(siteDir, {withFileTypes: true})) {
if (!entry.isFile() || !entry.name.endsWith('.mp3')) continue;
if (wanted.has(entry.name)) continue;
rmSync(join(siteDir, entry.name), {force: true});
}
}
function prerenderSite(
input: SitePayload,
outRoot: string,
@ -375,6 +424,8 @@ function prerenderSite(
*/
writeFile(siteDir, 'llms.txt', renderLlmsTxt(payload));
copySongs(payload, siteDir);
// 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다
// (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다.
if (!plan.shared) {

4
solution/site/songs/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# 생성된 노래 파일(mp3)은 커밋하지 않는다 — payload 와 같은 규칙이다.
# 발행할 때 백엔드가 다시 만들어 떨군다.
*
!.gitignore

View File

@ -505,6 +505,16 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
},
],
/**
* 노래는 비워 둔다.
*
* ★ 이 픽스처의 목적은 "payload 가 이러이러할 때 화면이 이렇게 나온다" 를 눈으로 보는 것이다.
* 노래는 발행 때 Suno 가 만들어 넣는 실제 파일을 가리키므로, 여기에 가짜 주소를 적으면
* 개발 서버에서 **재생만 안 되는 버튼**이 생긴다. 비어 있을 때 플레이어가 아예 안 그려지는지
* 확인하는 쪽이 이 픽스처가 할 일에 맞다.
*/
songs: [],
narrative: {
heroHeadline: '바람과 돌담이 품은 고요',
heroSubline: '제주 자연의 질감이 머무는 독채 스테이',

View File

@ -1,5 +1,6 @@
import {Menu, Phone} from 'lucide-react';
import {useSite} from '@site/lib/site-context';
import {SongPlayer} from '@site/sections/SongPlayer';
import {isSectionEnabled, stayBookingView, unitSpec} from '@site/lib/derive';
/**
@ -77,6 +78,9 @@ export function SiteHeader() {
</nav>
<div className="flex shrink-0 items-center gap-1.5">
{/* 이 숙소의 노래. 곡이 없으면 아무것도 그리지 않는다(SongPlayer 머리주석). */}
<SongPlayer />
{payload.place.phone && (
<a
href={`tel:${payload.place.phone}`}

View File

@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from "react";
import { ListMusic, Pause, Play } from "lucide-react";
import { useSite } from "@site/lib/site-context";
/**
* 이 숙소의 노래 — 헤더의 작은 플레이어.
*
* ★ 무엇인가 · 무엇이 아닌가
* 발행할 때 이 숙소의 사실로 쓴 가사에 곡을 붙여 만든 노래다(백엔드 `song_service`).
* 배경음악처럼 **저절로 틀지 않는다** — 손님이 누를 때만 난다. 자동 재생은 브라우저가
* 막기도 하거니와, 막지 않는 경우가 더 나쁘다(소리가 갑자기 나는 페이지는 닫힌다).
*
* ★ 곡이 없으면 아무것도 그리지 않는다.
* 발행은 노래를 기다리므로 보통은 있다. 그래도 없을 수 있다 — 키가 없거나(SUNO_API_KEY),
* 작곡이 실패하거나, 옛 버전으로 구운 사이트다. 그때 빈 플레이어를 그리면 손님에게는
* **고장난 버튼**이다. 없으면 없는 대로 둔다.
*
* ★ 가사를 함께 싣는다.
* 무슨 노래인지 읽히지 않으면 아무도 누르지 않는다. 그리고 이 가사는 화면에 있는 편이
* 검색·AI 쪽에도 맞다 — 오디오 안의 말은 크롤러가 못 듣는다.
*
* ★ 패널은 조건부 렌더가 아니라 `hidden` 으로 여닫는다.
* `{open && ...}` 로 두면 **닫힌 동안 곡 제목과 가사가 DOM 에 아예 없다.** 서버 렌더 결과가
* 곧 크롤러가 읽는 전부인데(정적 발행), 그러면 이 노래는 검색·AI 쪽에 존재하지 않는 것과
* 같아진다 — 오디오 안의 말은 크롤러가 못 듣기 때문에 가사가 유일한 단서다.
* 그래서 markup 은 늘 있고, 보이고 안 보이고만 바뀐다.
*/
export function SongPlayer() {
const payload = useSite();
const songs = payload.songs ?? [];
const [open, setOpen] = useState(false);
const [playing, setPlaying] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// 페이지를 떠나거나 컴포넌트가 사라질 때 소리를 끊는다 — 리액트가 노드를 지워도
// 재생 중인 오디오는 계속 난다(브라우저가 소리를 DOM 과 함께 정리하지 않는다).
useEffect(() => () => audioRef.current?.pause(), []);
if (songs.length === 0) return null;
const toggle = (songId: string, url: string) => {
const audio = audioRef.current;
if (!audio) return;
if (playing === songId) {
audio.pause();
setPlaying(null);
return;
}
// 다른 곡을 누르면 하던 것을 끊고 갈아탄다.
audio.src = url;
// 재생은 거절될 수 있다(자동재생 정책·네트워크). 그때 '재생 중' 으로 표시해 두면
// 소리는 안 나는데 멈춤 버튼만 보인다 — 성공했을 때만 상태를 바꾼다.
void audio.play().then(
() => setPlaying(songId),
() => setPlaying(null),
);
};
// 가사는 **고른 곡**의 것을 보여준다. 아직 아무것도 안 눌렀으면(서버 렌더 포함) 첫 곡이다 —
// 그래야 크롤러가 읽는 HTML 에도 가사가 들어간다.
const current = songs.find((song) => song.songId === playing) ?? songs[0];
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
aria-label="노래 목록"
title="노래 목록"
className="border-line hover:bg-card flex size-9 items-center justify-center rounded-md border transition-colors"
>
{playing ? (
<Pause className="size-4" aria-hidden />
) : (
<ListMusic className="size-4" aria-hidden />
)}
</button>
{/*
★ 오디오 태그는 패널 밖에 둔다. 패널 안에 두면 목록을 닫는 순간 노드가 사라져
듣던 노래가 끊긴다 — 닫고 계속 듣는 것이 이 플레이어의 기본 사용법이다.
★ preload="none": 아무도 안 누를 수도 있는 파일을 모든 방문자에게 내려받게 하지 않는다.
*/}
{/*
★ 첫 곡의 주소를 markup 에 박아 둔다. 누를 때 비로소 src 를 넣으면 **서버 렌더 결과에
오디오 주소가 한 글자도 없다** — 크롤러에게 이 페이지는 소리 없는 페이지다.
preload="none" 이라 주소만 있고 받아 오지는 않는다.
*/}
<audio
ref={audioRef}
src={songs[0]?.audioUrl}
preload="none"
onEnded={() => setPlaying(null)}
/>
<div
hidden={!open}
className="border-line absolute right-0 top-11 z-50 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-lg border shadow-lg"
style={{ backgroundColor: "var(--tpl-surface, #fff)" }}
>
<div className="border-line flex items-center justify-between border-b px-4 py-2.5">
<span className="serif text-[length:var(--fs-sm)]">
{payload.place.name}이(가) 만든 노래
</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="닫기"
className="text-muted px-1 text-lg leading-none"
>
×
</button>
</div>
<ul>
{songs.map((song, index) => (
<li
key={song.songId}
className="border-line border-b last:border-b-0"
>
<button
type="button"
onClick={() => toggle(song.songId, song.audioUrl)}
className="hover:bg-card flex w-full items-center gap-3 px-4 py-3 text-left transition-colors"
style={
playing === song.songId
? { color: "var(--color-brand)" }
: undefined
}
>
<span className="text-muted w-4 shrink-0 text-[length:var(--fs-xs)] tabular-nums">
{index + 1}
</span>
{playing === song.songId ? (
<Pause className="size-3.5 shrink-0" aria-hidden />
) : (
<Play className="size-3.5 shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-[length:var(--fs-sm)]">
{song.title}
</span>
<span className="text-muted shrink-0 truncate text-[length:var(--fs-xs)]">
{payload.place.name}
</span>
</button>
</li>
))}
</ul>
{current?.lyrics && (
<div className="border-line max-h-56 overflow-y-auto border-t px-4 py-3">
{/* 가사는 줄바꿈이 곧 구조다. Suno 가 읽는 구조 태그([Verse])는 손님에게 뜻이 없어 뺀다. */}
<p className="text-muted whitespace-pre-line text-[length:var(--fs-xs)] leading-relaxed">
{current.lyrics
.replace(/^\[.*\]$/gm, "")
.replace(/\n{3,}/g, "\n\n")
.trim()}
</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
/**
* 노래 플레이어 — 이 검사가 지키는 것.
*
* 1. 곡이 없으면 **아무것도 그리지 않는다.** 노래는 발행보다 2~3분 늦게 완성되므로
* "아직 없음" 이 정상 상태다. 그때 버튼을 그려 두면 손님에게는 고장난 버튼이다.
* 2. 곡이 있으면 제목·가사가 **HTML 에 들어간다.** 오디오 안의 말은 크롤러가 못 듣는다 —
* 가사가 서버 렌더 결과에 없으면 이 노래는 검색·AI 쪽에 존재하지 않는 것과 같다.
* 3. 재생 주소가 **우리 경로**다. Suno 가 준 주소는 만료되므로 발행본에 나가면 안 된다 —
* 나가면 발행 직후에는 재생되고 몇 주 뒤 조용히 죽는다.
* 4. 재생 주소가 없는 곡은 걸러진다(버튼만 있고 소리가 없는 자리를 만들지 않는다).
*/
import {describe, expect, it} from 'vitest';
import {sanitizePayloadForPublish, type SitePayload, type SongTrack} from '@o2o/shared';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import {render} from '@site/entry-server';
const SONG: SongTrack = {
songId: 'eb5e4efe-47f4-445e-bfa7-613482bb2cfc',
title: '시간이 머무는 자리',
lyrics: '[Verse]\n군산의 오랜 골목길 따라\n[Chorus]\n시간이 천천히 흐르는 이곳',
style: 'acoustic ballad',
durationSec: 59.2,
audioUrl: '/s/moonlight-stay-jeju/eb5e4efe-47f4-445e-bfa7-613482bb2cfc.mp3',
fileName: 'eb5e4efe-47f4-445e-bfa7-613482bb2cfc.mp3',
};
function withSongs(songs: SongTrack[]): SitePayload {
return {...MOONLIGHT_STAY_PAYLOAD, songs};
}
describe('노래 플레이어', () => {
it('곡이 없으면 플레이어를 그리지 않는다', () => {
const html = render(withSongs([]));
expect(html).not.toContain('노래 목록');
});
it('곡이 있으면 제목과 가사가 HTML 에 들어간다', () => {
const html = render(withSongs([SONG]));
expect(html).toContain('노래 목록');
expect(html).toContain('시간이 머무는 자리');
expect(html).toContain('군산의 오랜 골목길 따라');
});
it('★ 재생 주소는 우리 경로다 — Suno 주소(만료됨)가 발행본에 나가면 안 된다', () => {
const html = render(withSongs([SONG]));
expect(html).toContain('/s/moonlight-stay-jeju/eb5e4efe-47f4-445e-bfa7-613482bb2cfc.mp3');
expect(html).not.toContain('sunoapi.org');
expect(html).not.toContain('cdn1.suno.ai');
});
it('재생 주소가 없는 곡은 발행 전에 걸러진다', () => {
const cleaned = sanitizePayloadForPublish(withSongs([{...SONG, audioUrl: ''}]));
expect(cleaned.songs).toHaveLength(0);
});
});