o2o-site-AEO/solution/backend/services/song_service.py
hbyang 38bb4be9e5 [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>
2026-09-11 11:21:16 +09:00

259 lines
13 KiB
Python

"""이 숙소의 노래 — 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,
}