[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);
});
});