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건(
ListNoResultsCTA) 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, purposeCOMMERCIAL_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 로 오디오 교체 섹션 추가):
- 정보(DescCard) — 제목 · 재생 시간(
mm:ss + 초) · 활성 상태 · 음원 URL(<a target="_blank">) · 생성/수정 시각(KST). - 미리듣기 —
<audio controls preload="metadata" src={audioUrl}>(본사 안내방송 #062announcement-row-player미니 패턴 미러). cache-buster?v={updatedAt}는 audioUrl 자체가 바뀌지 않는 본 SPEC 에선 불필요. - 오디오 교체(#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 토스트 “오디오 파일이 교체되었습니다.”. 파일 문제 400COMMERCIAL_INVALID_FILE/ durationSeconds 범위 위반 = 전역 검증 400 / 티켓 만료 401 · 429 · 503. - 편집 form — 제목 input(1~200자) + 활성 토글(
SwitchRadix). [저장] →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) 유지. - 삭제 — 빨간 [삭제] 버튼 + 2-step 인라인 confirm strip (”…CM송을 삭제합니다. 이 작업은
되돌릴 수 없습니다.” + [취소]/[삭제 확정]). 시안 부재로 본사 안내방송
DeleteAnnouncementDialog(2-step)의 변형 inline 미러. 성공 시 캐시 invalidate +/admin/commercialspush, 404COMMERCIAL_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 참조.
| Method | Path | operationId | 비고 |
|---|---|---|---|
| GET | /api/v1/hq/commercials | listHqCommercials | q?·isActive?·page·size. 정렬 created_at DESC, id ASC |
| POST | /api/v1/hq/commercials | createHqCommercial | multipart(#174) — file body + title·durationSeconds query. 201 + HqCommercialDetailResponse. audit HQ_COMMERCIAL_CREATED |
| PUT | /api/v1/hq/commercials/{id}/file | replaceHqCommercialFile | #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} | updateHqCommercial | body UpdateHqCommercialRequest(title/isActive 각 null=미변경). 두 필드 모두 null = no-op 200 |
| DELETE | /api/v1/hq/commercials/{id} | deleteHqCommercial | soft-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_songsV32 + 본사 UI).F2 매장별 사이클 빈도 override✅ SPEC #103 완료(store.commercial_cycle_songsV33 + 본사 매장 상세 편집).F3 CM 라운드로빈 정책✅ SPEC #104 완료(store.last_commercial_song_idV34 매장 단위 순환 — 랜덤에서 결정적 라운드로빈으로 전환, 같은 CM 연속·1건 미노출 문제 해소).- F4 CM송 라이브러리 묶음 — 음원/플레이리스트 2계층의 별도 카테고리화(라이브러리 ↔ CM송 매핑).
F5 audit 누적✅ SPEC #174 완료 —HQ_COMMERCIAL_CREATED·HQ_COMMERCIAL_FILE_REPLACED액션이HqAuditItemAction에 합류(본사 audit 목록·CSV 에 노출). 등록/파일 교체가 감사 로그로 남는다.