[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:
parent
703f831273
commit
38bb4be9e5
@ -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(웹 애플리케이션)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
|
||||
|
||||
@ -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회.
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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
|
||||
|
||||
30
postgres-init/migrations/0010_place_songs.sql
Normal file
30
postgres-init/migrations/0010_place_songs.sql
Normal 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);
|
||||
@ -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
|
||||
# ============================================================
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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_*) 로 주입받는다.
|
||||
|
||||
39
solution/backend/crud/song_crud.py
Normal file
39
solution/backend/crud/song_crud.py
Normal 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),
|
||||
)
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
158
solution/backend/services/external/suno.py
vendored
Normal file
158
solution/backend/services/external/suno.py
vendored
Normal 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
|
||||
62
solution/backend/services/prompts/song.py
Normal file
62
solution/backend/services/prompts/song.py
Normal 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)}.
|
||||
"""
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
258
solution/backend/services/song_service.py
Normal file
258
solution/backend/services/song_service.py
Normal 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,
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
4
solution/site/songs/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
# 생성된 노래 파일(mp3)은 커밋하지 않는다 — payload 와 같은 규칙이다.
|
||||
# 발행할 때 백엔드가 다시 만들어 떨군다.
|
||||
*
|
||||
!.gitignore
|
||||
@ -505,6 +505,16 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* 노래는 비워 둔다.
|
||||
*
|
||||
* ★ 이 픽스처의 목적은 "payload 가 이러이러할 때 화면이 이렇게 나온다" 를 눈으로 보는 것이다.
|
||||
* 노래는 발행 때 Suno 가 만들어 넣는 실제 파일을 가리키므로, 여기에 가짜 주소를 적으면
|
||||
* 개발 서버에서 **재생만 안 되는 버튼**이 생긴다. 비어 있을 때 플레이어가 아예 안 그려지는지
|
||||
* 확인하는 쪽이 이 픽스처가 할 일에 맞다.
|
||||
*/
|
||||
songs: [],
|
||||
|
||||
narrative: {
|
||||
heroHeadline: '바람과 돌담이 품은 고요',
|
||||
heroSubline: '제주 자연의 질감이 머무는 독채 스테이',
|
||||
|
||||
@ -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}`}
|
||||
|
||||
167
solution/site/src/sections/SongPlayer.tsx
Normal file
167
solution/site/src/sections/SongPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
solution/site/src/sections/song-player.test.tsx
Normal file
56
solution/site/src/sections/song-player.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user