FeaturesHQ (본사)HQ Mode CM송 관리 (apps/space /admin/commercials)

HQ Mode — 본사 CM송 관리 (apps/space /admin/commercials)

SPEC #093 도입. 실 HQ_MANAGER 직접 로그인 표면(apps/space · space.linkmusic.io) — 운영사 임퍼소네이션 (apps/admin)이 아니다.

이 페이지는 apps/space 의 실 HQ_MANAGER 본사 모드다. 본사가 광고/공지 음원(CM송)을 등록· 관리하고, 점장 player 가 N곡마다 1회 자동 재생하는 백본이다. 점장 player 의 CM송 사이클 재생은 구현 완료(SPEC #094·#095·#103·#104, 아래 §점장 player 자동 재생) — 재생 모델은 SPEC #141 의 오버레이 동시재생이라 음악은 멈추지 않고 더킹된 채 계속 흐른다.

Overview

본사(HQ_MANAGER) 가 apps/space 본사 모드에서 광고/공지 음원(CM송)을 직접 생성·조회·편집·삭제 한다. 등록·파일 교체는 MP3 파일 업로드(SPEC #174 — 서버가 검증·Azure blob 저장·audioUrl 자동 채움, 음원 업로드와 동일 백본). 가시 범위는 본인 본사의 활성 CM송만(다른 본사 CM송 비노출, BE D3). 본사 안내방송(#061)이 “TTS 합성으로 음원을 만들어 매장에 송출” 한다면 CM송은 “업로드한 광고/공지 음원을 점장 player 가 N곡마다 1회 자동 재생” 하는 다른 도메인이다(재생기 연동은 SPEC #094 에서 완료).

시안 출처: workspace parent dir — hq-commercial 전용 시안 부재로 본사 모드 기존 화면 (design/screens/hq-stores.jsx 테이블·검색·페이지네이션, hq-library 목록 #080) 스타일과 일관 (임의 디자인 금지). 검색·페이지네이션은 공용 ListToolbar/ListPagination(본사 /stores #051 관용구), 폼 입력은 Field+Input(본사 안내방송 #061 idiom) 미러. 상세는 본사 매장 상세(#084) DescCard + 본사 안내방송(#061) <audio controls> 미니 플레이어 idiom 미러.

목록 (/admin/commercials)

apps/space/src/app/admin/commercials/page.tsx(server 셸) + hq-commercial-list-client.tsx(client). 본사 라이브러리(#080)와 동일 이유로 서버사이드 페이지네이션 + client-query (useListHqCommercials) — q(제목)·isActive(활성/비활성/전체)·page·size 를 client state 로 보유한다. 정렬은 서버 고정(created_at DESC, id ASC, BE D5).

  • 헤더: 제목 “CM송” + [새 CM송 등록] → /admin/commercials/new.
  • 툴바: 공용 ListToolbar — 검색(제목, ≤100) + 활성 필터(활성/비활성/전체) + 적용/초기화.
  • : 제목(셀 클릭 → /admin/commercials/{id}) · 재생 시간(mm:ss 포맷) · 활성 배지 (StatusPill — 활성=success/비활성=muted) · 등록 시각(KST) · 수정 시각(KST).
  • 빈 상태: 검색/필터 0건(ListNoResults CTA) vs 진짜 빈 목록(“CM송이 없습니다.”).
  • 페이지네이션: 공용 ListPagination — 총 N개 + 이전/다음(경계 disabled).

등록 (/admin/commercials/new)

apps/space/src/app/admin/commercials/new/page.tsx(server 셸) + hq-commercial-new-client.tsx(client). useCreateHqCommercial multipart mutation. SPEC #174: URL 입력 → MP3 파일 업로드로 전환 (음원 업로드 UI 관례 미러). 폼 필드:

  • 제목 — text input, 1~200자. 파일 선택 시 파일명(확장자 제거)으로 자동 제안(비어 있을 때만) — 수정 가능.
  • 음원 파일 (MP3)type="file" accept="audio/mpeg,.mp3". 선택 즉시 클라 사전검증 (확장자/MIME·빈 파일·20MB) + Web Audio(<audio> loadedmetadata)로 durationSeconds 추출 (#174 D4 — 서버 파서 불필요). 추출된 재생 시간은 mm:ss (초) 로 표시. 비-MP3·빈·20MB·길이 읽기 실패는 인라인 Banner danger(hq-commercial-new-file-error)로 안내하고 submit 차단.

파일 미선택·추출 중·제목 범위 위반은 disabled 로 차단해 불필요한 BE 호출을 절약한다(frontend.md §8). 제출 시 공용 uploadViaTicket(COMMERCIAL_CREATE)이 file·title·durationSeconds(폼 필드)를 전송하면 서버가 파일을 검증(MP3 magic byte·20MB·비어있지 않음 · #174 D2)한 뒤 Azure blob 에 저장하고 audioUrl 을 채운다. 성공 시(HqCommercialDetailResponse) id/admin/commercials/{id} push. 실패는 code→메시지 매핑: 파일 문제(빈·비-MP3·20MB) COMMERCIAL_INVALID_FILE / title·durationSeconds 범위 위반은 전역 검증 400(별개) / 403 권한 / 티켓 만료 401 UPLOAD_TICKET_INVALID / 429 RATE_LIMITED / 503 UPLOAD_NOT_CONFIGURED / 5xx 서버 / BACKEND_UNREACHABLE.

SPEC #177 — 파일 본문은 백엔드 직접 업로드로 우회. CM 등록·파일 교체 모두 파일 본문을 createHqCommercial/replaceHqCommercialFile 로 직접 보내지 않는다(브라우저 → Vercel BFF proxy 는 서버리스 4.5MB 리밋에 막힌다). 대신 업로드 티켓(POST /api/v1/uploads/tickets, purpose COMMERCIAL_CREATE/ COMMERCIAL_REPLACE)을 받아 파일 multipart 를 백엔드로 직접(cross-origin) POST(POST /api/v1/uploads/files, X-Upload-Ticket 헤더)한다. 교체 대상 id 는 티켓에만 실린다(요청 파라미터 신뢰 금지). 공용 uploadViaTicket 헬퍼(@linkmusic/api-client)가 티켓 발급(proxy) → XHR 직접 업로드(진행률 %) → 성공 시 목록·상세 invalidate 를 담당한다. 아래 endpoint 계약·서버 로직은 그대로이며 티켓 경로가 이들 서비스에 dispatch 한다.

상세 (/admin/commercials/[id])

apps/space/src/app/admin/commercials/[id]/page.tsx(server 셸) + hq-commercial-detail-client.tsx (client). useGetHqCommercialDetail(id) + useReplaceHqCommercialFile() + useUpdateHqCommercial()

  • useDeleteHqCommercial() 4 훅. 5 섹션(#174 로 오디오 교체 섹션 추가):
  1. 정보(DescCard) — 제목 · 재생 시간(mm:ss + 초) · 활성 상태 · 음원 URL(<a target="_blank">) · 생성/수정 시각(KST).
  2. 미리듣기<audio controls preload="metadata" src={audioUrl}> (본사 안내방송 #062 announcement-row-player 미니 패턴 미러). cache-buster ?v={updatedAt} 는 audioUrl 자체가 바뀌지 않는 본 SPEC 에선 불필요.
  3. 오디오 교체(#174 D3 → #177 직접 업로드) — 새 MP3 파일 선택(accept="audio/mpeg,.mp3") + duration 재추출(등록과 동일 Web Audio 관례) → 공용 uploadViaTicket(COMMERCIAL_REPLACE, 대상 id 는 티켓에만·durationSeconds 폼 필드). 파일 본문은 Vercel 4.5MB proxy 리밋을 우회해 백엔드로 직접 전송한다. 같은 blob key 를 덮어써 audioUrl(동일 URL·내용 교체)·durationSeconds 를 갱신한다(제목·활성 상태는 편집 섹션과 독립). 클라 사전검증(비-MP3·빈·20MB)은 등록과 동일하게 hq-commercial-replace-error 배너로 안내. 성공 시 상세·목록 캐시 invalidate + success 토스트 “오디오 파일이 교체되었습니다.”. 파일 문제 400 COMMERCIAL_INVALID_FILE / durationSeconds 범위 위반 = 전역 검증 400 / 티켓 만료 401 · 429 · 503.
  4. 편집 form — 제목 input(1~200자) + 활성 토글(Switch Radix). [저장] → useUpdateHqCommercial. PATCH 가 title?: string|null·isActive?: boolean|null (null=미변경, BE D2)이라 변경 필드만 보내도 되지만 항상 두 필드 다 전송해 단순화(BE D6 검증 통과 + DB dirty-checking 으로 실제 UPDATE 절약). 저장 성공 시 상세·목록 캐시 모두 invalidate + success 토스트 “저장되었습니다.”(SPEC #163 — 인라인 배너 대신 공용 ToastHost). 편집 에러는 인라인 Banner danger (hq-commercial-edit-error) 유지.
  5. 삭제 — 빨간 [삭제] 버튼 + 2-step 인라인 confirm strip (”…CM송을 삭제합니다. 이 작업은 되돌릴 수 없습니다.” + [취소]/[삭제 확정]). 시안 부재로 본사 안내방송 DeleteAnnouncementDialog (2-step)의 변형 inline 미러. 성공 시 캐시 invalidate + /admin/commercials push, 404 COMMERCIAL_SONG_NOT_FOUND(이미 삭제됨) 도 동일 처리.

상태:

  • 로딩: 3-block 스켈레톤(본사 매장 상세 #084 미러).
  • 404 COMMERCIAL_SONG_NOT_FOUND(타 본사·미존재 모두 은닉): Banner danger “CM송을 찾을 수 없습니다.”
  • 4xx/5xx/네트워크: Banner danger 일반 메시지.

사이드바

HQSidebar 의 “CM송” 항목이 enabled(SPEC #093). placeholder 경로 /admin/cm 가 본 슬라이스에서 /admin/commercials 로 정렬됐다(라이브러리 #080 의 /admin/library/admin/libraries 정렬과 동형). icon Volume2 (lucide) 유지.

인가 / endpoint

6 endpoint 전부 HQ_MANAGER-only — /api/v1/hq/** prefix 매처 → hasRole("HQ_MANAGER") 1차 경계 + service verifyHqScope claim↔DB 재검증. 미인증 401 · 비활성/role·소속 불일치 403 PRINCIPAL_SCOPE_MISMATCH. 메타 조회·수정·삭제 호출은 BFF catch-all /api/backend/... 경유(토큰 서버 전용). 파일 업로드(create·replace)는 SPEC #177 부터 catch-all 을 타지 않고 업로드 티켓 → POST /api/v1/uploads/files(백엔드 직접·cross-origin) 로 우회한다(Vercel 서버리스 4.5MB 리밋 회피). 티켓 발급 (POST /api/v1/uploads/tickets)만 proxy 경유. 자세한 계약은 Endpoints · Uploads 참조.

MethodPathoperationId비고
GET/api/v1/hq/commercialslistHqCommercialsq?·isActive?·page·size. 정렬 created_at DESC, id ASC
POST/api/v1/hq/commercialscreateHqCommercialmultipart(#174) — file body + title·durationSeconds query. 201 + HqCommercialDetailResponse. audit HQ_COMMERCIAL_CREATED
PUT/api/v1/hq/commercials/{id}/filereplaceHqCommercialFile#174 신규 — multipart file body + durationSeconds query. 같은 blob 덮어쓰기 → 200. audit HQ_COMMERCIAL_FILE_REPLACED
GET/api/v1/hq/commercials/{id}getHqCommercialDetail단건 — 404 COMMERCIAL_SONG_NOT_FOUND(타 본사/미존재 은닉)
PATCH/api/v1/hq/commercials/{id}updateHqCommercialbody UpdateHqCommercialRequest(title/isActive 각 null=미변경). 두 필드 모두 null = no-op 200
DELETE/api/v1/hq/commercials/{id}deleteHqCommercialsoft-delete 204

자세한 schema 는 DTOs · Hq Commercial DTOs · endpoint 카탈로그는 Endpoints · HQ Mode 참조.

점장 player 자동 재생 (SPEC #094 · #095 · #103 · #104)

본사가 등록한 활성 CM송은 점장 player(/store)에서 effective 빈도 N곡마다 1회 (본사 default + 매장 override) 자동 재생된다. 신규 endpoint getStoreNextCommercial(GET /api/v1/store/commercial-song/next, STORE_MANAGER-only) — 본인 매장 본사의 활성 CM 중 매장 단위 라운드로빈 다음 1건(SPEC #104, V34 store.last_commercial_song_id 기반 — id > last 다음 row 또는 wrap-around 로 첫 row, 다음 호출용 last 갱신) 또는 204(CM 미등록). 점장 player 가 음악 곡 ended 카운터가 effective 빈도에 도달하면 imperative 호출 → 200 시 CM 을 오버레이로 동시재생(SPEC #141 — 음악은 정지하지 않고 더킹된 채 다음 곡으로 계속 흐른다) + CM ended 후 카운터 리셋 + 음악 볼륨 복원, 204/실패 시 silent(음악 그대로 진행). 인터럽트 우선순위는 긴급 > 일반 안내방송 > CM(CM 재생 중 안내방송 도착 시 CM 폐기 후 안내방송 우선). 자세한 재생 흐름은 Store Player Home — CM송 사이클 참조.

Followups

  • F1 본사 사이클 빈도 설정 ✅ SPEC #095 완료(hq.commercial_cycle_songs V32 + 본사 UI).
  • F2 매장별 사이클 빈도 override ✅ SPEC #103 완료(store.commercial_cycle_songs V33 + 본사 매장 상세 편집).
  • F3 CM 라운드로빈 정책 ✅ SPEC #104 완료(store.last_commercial_song_id V34 매장 단위 순환 — 랜덤에서 결정적 라운드로빈으로 전환, 같은 CM 연속·1건 미노출 문제 해소).
  • F4 CM송 라이브러리 묶음 — 음원/플레이리스트 2계층의 별도 카테고리화(라이브러리 ↔ CM송 매핑).
  • F5 audit 누적 ✅ SPEC #174 완료 — HQ_COMMERCIAL_CREATED·HQ_COMMERCIAL_FILE_REPLACED 액션이 HqAuditItemAction 에 합류(본사 audit 목록·CSV 에 노출). 등록/파일 교체가 감사 로그로 남는다.