diff --git a/.env.example b/.env.example index 0caa193..045f3f4 100644 --- a/.env.example +++ b/.env.example @@ -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(웹 애플리케이션) diff --git a/docker-compose.yml b/docker-compose.yml index dc314d8..64e3ba9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2200a66..46ef371 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,8 +13,14 @@ ``` backend (Python) ──쓴다──▶ out/payloads/.json ◀──읽는다── prerender (Node) out/payloads/.status/.json ──보고──▶ + ──쓴다──▶ out/songs/.mp3 ◀──복사── ``` +★ 노래(mp3)도 **같은 약속**을 쓴다(2026-09-11). 백엔드는 발행물 디렉토리를 모른 채 파일을 +`out/songs/` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s//` 로 복사한다. 백엔드가 발행물 +디렉토리에 직접 쓰기 시작하면 이 경계가 무너진다 — 그때부터 두 쪽이 out/ 의 모양을 함께 +알아야 한다. + 이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다. **둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가 React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 019f8fa..0e0c235 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -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/.mp3` +에 두고, 프리렌더가 사이트 디렉토리로 복사한 것(`/s//.mp3`)만 나간다. +표에는 추적용으로만 남긴다. + +★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `latest_ready` 가 `READY` 중 최신 하나를 고른다. + ### `area_contents` + `place_area_refs` — 지역 콘텐츠 ★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index c632067..1b63c4d 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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//.mp3`)만 발행본에 내보낸다. + +**★ 콜백이 아니라 폴링이다** +Suno 는 `callBackUrl` 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다. +이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면 +"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다. +(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.) + +**경계는 그대로다** — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로 +`out/songs/.mp3` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s//` 로 복사한다. +프리렌더는 복사하면서 **지난 발행의 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`, diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index f625d61..83802c1 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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 diff --git a/postgres-init/migrations/0010_place_songs.sql b/postgres-init/migrations/0010_place_songs.sql new file mode 100644 index 0000000..335ebe2 --- /dev/null +++ b/postgres-init/migrations/0010_place_songs.sql @@ -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); diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 2f03146..dff7fc2 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -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 # ============================================================ diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index defab18..abc4d1b 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -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): diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index 1b18d15..245856e 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -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_*) 로 주입받는다. diff --git a/solution/backend/crud/song_crud.py b/solution/backend/crud/song_crud.py new file mode 100644 index 0000000..1b4400c --- /dev/null +++ b/solution/backend/crud/song_crud.py @@ -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), + ) diff --git a/solution/backend/services/azure_static.py b/solution/backend/services/azure_static.py index fb0d22e..8fea4e7 100644 --- a/solution/backend/services/azure_static.py +++ b/solution/backend/services/azure_static.py @@ -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" diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 3ec9952..60d6b6f 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -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): diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index 83f03f1..05419a2 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -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") diff --git a/solution/backend/services/external/suno.py b/solution/backend/services/external/suno.py new file mode 100644 index 0000000..b646836 --- /dev/null +++ b/solution/backend/services/external/suno.py @@ -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 diff --git a/solution/backend/services/prompts/song.py b/solution/backend/services/prompts/song.py new file mode 100644 index 0000000..22a9991 --- /dev/null +++ b/solution/backend/services/prompts/song.py @@ -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)}. +""" diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 1f2de94..30ffbb6 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -955,6 +955,25 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: # ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석). # 렌더러는 비면 해당 섹션을 그리지 않는다. "routes": [], + # ★ 이 숙소의 노래. `audioUrl` 은 **우리 쪽 경로**다 — Suno 가 준 주소는 만료되므로 + # 파일을 받아 두고(song_service) 프리렌더가 사이트 디렉토리로 복사한 것을 가리킨다. + # 경로를 여기서 만드는 이유: 발행본의 주소 규칙(basePath + /s/)을 아는 곳이 여기다. + "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/`(또는 서브패스 마운트라면 그 앞에 접두어)다. + "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, } diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 788151b..9197f07 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -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 diff --git a/solution/backend/services/snapshot.py b/solution/backend/services/snapshot.py index bea1ef3..441d353 100644 --- a/solution/backend/services/snapshot.py +++ b/solution/backend/services/snapshot.py @@ -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/` 에 있고, + # 프리렌더가 그걸 사이트 디렉토리로 복사한다(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 diff --git a/solution/backend/services/song_service.py b/solution/backend/services/song_service.py new file mode 100644 index 0000000..ba99a3f --- /dev/null +++ b/solution/backend/services/song_service.py @@ -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//` 안에 있으므로 `azure_static.publish(slug)` 가 사이트를 통째로 + 올릴 때 함께 올라간다(content-type `audio/mpeg`, 해시 파일명이라 immutable 캐시). + 지난 곡은 `_remove_stale_site_files` 가 블롭에서도 지운다. + → 업로더를 하나 더 두면 같은 컨테이너에 **두 규칙**이 생긴다(경로·캐시·정리 주체). + 참고 프로젝트(o2o-castad-backend)는 사이트가 없어 직접 올릴 수밖에 없었지만, + 여기서는 노래가 사이트의 일부라 사이트와 같은 길로 나가는 것이 맞다. + 덕분에 주소도 같은 오리진(`/s//.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, + } diff --git a/solution/backend/worker/handlers.py b/solution/backend/worker/handlers.py index 696334b..05da067 100644 --- a/solution/backend/worker/handlers.py +++ b/solution/backend/worker/handlers.py @@ -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() diff --git a/solution/shared/src/lib/facts.ts b/solution/shared/src/lib/facts.ts index 8c7c14f..8558113 100644 --- a/solution/shared/src/lib/facts.ts +++ b/solution/shared/src/lib/facts.ts @@ -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()), }; } diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 8b017f5..05bb1ff 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -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//<파일>`). + * + * ★ Suno 가 준 주소를 그대로 쓰지 않는다 — 만료되는 주소라, 발행 직후에는 재생되고 + * 몇 주 뒤 조용히 죽는다. 백엔드가 파일을 받아 두고 프리렌더가 사이트로 복사한다. + */ + audioUrl: string; + /** 프리렌더가 원본을 찾을 때 쓰는 파일명. 렌더러는 쓰지 않는다. */ + fileName?: string; +} + export interface SiteTheme { templateId: string; colors: { diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index d356bad..8342b9c 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -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(); + + 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) { diff --git a/solution/site/songs/.gitignore b/solution/site/songs/.gitignore new file mode 100644 index 0000000..f478042 --- /dev/null +++ b/solution/site/songs/.gitignore @@ -0,0 +1,4 @@ +# 생성된 노래 파일(mp3)은 커밋하지 않는다 — payload 와 같은 규칙이다. +# 발행할 때 백엔드가 다시 만들어 떨군다. +* +!.gitignore diff --git a/solution/site/src/fixtures/moonlight-stay.ts b/solution/site/src/fixtures/moonlight-stay.ts index 42225e2..6e0805d 100644 --- a/solution/site/src/fixtures/moonlight-stay.ts +++ b/solution/site/src/fixtures/moonlight-stay.ts @@ -505,6 +505,16 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { }, ], + /** + * 노래는 비워 둔다. + * + * ★ 이 픽스처의 목적은 "payload 가 이러이러할 때 화면이 이렇게 나온다" 를 눈으로 보는 것이다. + * 노래는 발행 때 Suno 가 만들어 넣는 실제 파일을 가리키므로, 여기에 가짜 주소를 적으면 + * 개발 서버에서 **재생만 안 되는 버튼**이 생긴다. 비어 있을 때 플레이어가 아예 안 그려지는지 + * 확인하는 쪽이 이 픽스처가 할 일에 맞다. + */ + songs: [], + narrative: { heroHeadline: '바람과 돌담이 품은 고요', heroSubline: '제주 자연의 질감이 머무는 독채 스테이', diff --git a/solution/site/src/sections/SiteHeader.tsx b/solution/site/src/sections/SiteHeader.tsx index 06b945d..f9cbb03 100644 --- a/solution/site/src/sections/SiteHeader.tsx +++ b/solution/site/src/sections/SiteHeader.tsx @@ -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() {
+ {/* 이 숙소의 노래. 곡이 없으면 아무것도 그리지 않는다(SongPlayer 머리주석). */} + + {payload.place.phone && ( (null); + const audioRef = useRef(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 ( +
+ + + {/* + ★ 오디오 태그는 패널 밖에 둔다. 패널 안에 두면 목록을 닫는 순간 노드가 사라져 + 듣던 노래가 끊긴다 — 닫고 계속 듣는 것이 이 플레이어의 기본 사용법이다. + ★ preload="none": 아무도 안 누를 수도 있는 파일을 모든 방문자에게 내려받게 하지 않는다. + */} + {/* + ★ 첫 곡의 주소를 markup 에 박아 둔다. 누를 때 비로소 src 를 넣으면 **서버 렌더 결과에 + 오디오 주소가 한 글자도 없다** — 크롤러에게 이 페이지는 소리 없는 페이지다. + preload="none" 이라 주소만 있고 받아 오지는 않는다. + */} +
+ ); +} diff --git a/solution/site/src/sections/song-player.test.tsx b/solution/site/src/sections/song-player.test.tsx new file mode 100644 index 0000000..a78f421 --- /dev/null +++ b/solution/site/src/sections/song-player.test.tsx @@ -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); + }); +});