Pending Features (후속 SPEC 예고)
기준 BE v0.80 / FE v1.00 / SPEC #001~#096 (#009 결번) — 2026-06-11.
우선 (베타 핵심)
| 항목 | 영역 | 예상 SPEC |
|---|---|---|
| BE + FE | SPEC #018 완료 — suspend/reactivate endpoint + hq-list 액션 + 세션 무효화·로그인 차단 | |
| BE + FE | SPEC #024 완료 — suspend SuspendRequest{ reason }(NotBlank·max 255) 저장 + HqStatusResponse·HqDetailResponse.suspensionReason + 다이얼로그 정지 사유 입력 · 상세 개요 “정지 사유” 행 · reactivate 시 clear | |
| HQ status 자동 전환 | BE (+ FE) | ONBOARDING→ACTIVE · UNPAID 결제 실패 자동 전환 (트리거/정산 도메인 미구축 의존 — #024 에서 정지 사유와 분리한 후속) |
/hq/:id) | BE + FE | SPEC #020 완료 — GET /api/v1/admin/hq/{id} + 5 탭 (개요 / 매장 / 결제·활동 placeholder / 계정). store admin-list ?hqId 스코프 · 행 [상세] 네비. 임시 레이아웃(handoff ops-hq-detail 시안 부재 — 추후 교체) |
/hq) 검색·필터·페이지네이션 | BE + FE | SPEC #013 + #045 완료 — GET /api/v1/admin/hq/admin-list(operationId adminListHqs). #045: 서버사이드 검색(q: 본사명·사업자번호)·status/type/plan 필터·페이지네이션(envelope HqAdminListResponse{items,page,size,total}, 매장 #044 미러). client-query(useAdminListHqs) 전환·정지/복구 mutation 후 현재 필터 invalidate·status 탭 서버 필터화(count 제거). 이로써 3대 어드민 목록(운영자 #043·매장 #044·본사 #045) 페이지네이션 완결. packages/ui ListToolbar/ListPagination/ListNoResults 공유. #117 은 client-query 목록(운영자·매장·본사·감사 hq/store) 에 글자단위로 중복되던 순수 파생 계산을 apps/admin/src/lib/list-query.ts 로 추출(computeListPagination(total,page,size)·clampPage·resolveListErrorMessage(query, mapError)) → 5개 목록 client 치환. 동작·마크업·testid·a11y 0 변경(순수 리팩터). draft/applied state 머신·필터 필드 집합은 목록마다 달라 호출부 유지(과도 추상화 회피 — D2). audit-actions/tickets 는 size 산술 변형(Math.max(1, size))이라 회귀 회피 위해 제외. 단위 테스트 list-query.test.ts 추가. |
| BE + FE | 이미 구현됨 (factcheck 정정) — SPEC #011 store onboarding POST /api/v1/admin/hq/{hqId}/stores 가 커버 (가맹 매장 등록 흐름 존재) | |
| BE + FE | SPEC #021 완료 — POST /api/v1/admin/stores/{storeId}/managers + /stores 행 [계정 발급] 다이얼로그 · StoreAdminListItem.hasManagerAccount 배지 분기. 시안대로 구현(ops-store-manager IssueManagerDialog — SPEC #031-E 시각 교체: 헤더 subtitle·매장 info chip·임시 비번 mono input·인라인 danger Banner, 관리 다이얼로그 #029 와 시각 일관) | |
| BE + FE | SPEC #029 완료 — GET/POST/DELETE .../stores/{storeId}/managers... 5 endpoint(목록·비번 재설정·정지·복구·회수) + /stores [발급됨 · 관리] 진입점 → 관리 다이얼로그. 상태머신 ACTIVE ↔ SUSPENDED · → WITHDRAWN(terminal). 상태별 액션 노출 · 파괴적 액션 2-step 확인 · KST 표기. 시안대로 구현(ops-store-manager ManageManagerDialog) | |
/stores) | BE + FE | SPEC #019 + #044 완료 — GET /api/v1/admin/stores/admin-list + /stores 목록. #044: 서버사이드 검색(q: 매장명·본사명·주소)·status/type/plan 필터·페이지네이션(envelope {items,page,size,total}, 운영자 #043 미러). client-query 전환·closedAt 폐점 배지·HQ 상세 매장 탭 desync 수정({hqId,size:100}) |
/stores/:id) | BE + FE | SPEC #036 완료 — GET /api/v1/admin/stores/{id}(StoreDetailResponse — 개요 + managers) + /stores/[id] 4 탭(개요 / 점장 계정 / 결제·활동 placeholder). 점장 탭은 #021 발급·#029 관리 진입점 재사용. 매장명 셀 → 상세 네비. HQ 상세(#020) 대칭. 시안 ops-store-detail(배치6 ④) 정합. |
| BE + FE | SPEC #037 완료 — suspend/reactivate endpoint(ACTIVE|INACTIVE ↔ SUSPENDED) + 매장 상세 헤더 status 기반 [정지]/[복구] 2-step 다이얼로그 + 소속 점장 세션 무효화·AUTH_STORE_SUSPENDED 인증 차단·정지 사유 영속. SPEC #039 완료 — 매장 폐점(closeStore) — POST .../close(closed_at 채움, 비가역 terminal·status 독립) + 헤더 [폐점] danger 2-step 다이얼로그(비가역 경고·사유 필수) + 폐점 매장 액션 전부 숨김·“폐점됨” 배지 + 세션 무효화·AUTH_STORE_CLOSED 영구 차단. 이미 폐점 → 409 STORE_ALREADY_CLOSED. 감사 STORE_CLOSED. 시안 ops-store-detail §StoreTransitionDialog 정합(HQ 상태전이 다이얼로그와 시각 일관). 후속: 폐점 복구(reopen) | |
| 대시보드 stats — 장애·CS·방송 | BE + FE | 계산 가능한 지표는 SPEC #017 GET /api/v1/admin/stats 로 연동 완료. 장애·CS·방송 stats 는 해당 도메인 도착 시 /admin/stats 확장 |
/stats) | FE | SPEC #017+#035+#126 — /stats ComingSoon 졸업. ops-dashboard-stats 시안의 분포 그리드(매장 상태·계정·CS 티켓·본사 유형/상태) + 30일 본사 가입 추이를 useGetAdminStats 실데이터로 구현(전용 상세 뷰 — 대시보드 종합 허브와 공유 컴포넌트 재사용·중복 회피). #126: 송출 운영 지표(status 분포·도달률 FE 파생·7일/30일 송출)·매장 audit·티켓 해결률 추가, 라벨 “운영 통계”(매출 미언급). 로딩/데이터0/에러 상태. 요금제 분포(TRUST/AI)는 응답 필드 없어 미표시(Stats) |
/settings/policies) | FE | SPEC #023 완료 + SPEC #033 확장 완료 — /settings/policies 2섹션(이용약관·개인정보처리방침). 현재 유효본 표시(GET .../active — #033 “현재 유효본”=effective_at<=now 중 최신, 404 미게시 빈상태) + [새 버전 게시] 다이얼로그. #033: ✅ 버전 이력 조회(이력 다이얼로그 — listTermsHistory/by-id 상세) · ✅ 예약 게시(effectiveAt datetime-local KST→UTC, 빈값=즉시·미래=예약·과거 거부) · ✅ 마크다운 렌더(react-markdown+rehype-sanitize <MarkdownView> — 활성본·이력 상세·게시 작성/미리보기 탭). 에러 매핑(LEGAL_DOC_DUPLICATE_VERSION·LEGAL_DOC_INVALID_EFFECTIVE_AT·LEGAL_DOC_NOT_FOUND·LEGAL_DOC_ACTIVATION_CONFLICT·403·5xx). #038: ✅ 예약 게시 취소 — 이력 상세 view SCHEDULED·미발효 한정 [예약 취소] danger(2-step 확인) → cancelTermsSchedule/cancelPrivacyPolicySchedule(DELETE 204) → history invalidate + 목록 복귀. 에러 LEGAL_DOC_NOT_SCHEDULED(409)·LEGAL_DOC_NOT_FOUND(404) 인라인 매핑. 감사 TERMS_SCHEDULE_CANCELED·PRIVACY_POLICY_SCHEDULE_CANCELED. #033·#038 추가 UI 는 임시 레이아웃(ops-policies-history 후속). |
| BE + FE | SPEC #022 완료 — FE layout redirect(주 경로) + BE PasswordChangeEnforcementFilter 403 PASSWORD_CHANGE_REQUIRED(allowlist 밖 보호 endpoint 차단, claim pmc 판정) 이중 강제. 임퍼소네이션 무영향 | |
| BE + FE | SPEC #040 완료 — POST /api/v1/legal/{terms,privacy}/agree(agreeTerms·agreePrivacy, LegalAgreeRequest{version} → 204 멱등, role 별 consent INSERT·임퍼소네이션 403·INVALID_LEGAL_VERSION 400). FE /admin 셸 layout gating(우선순위 passwordMustChange → 재동의) → /onboarding/reagree 강제 화면(필요 항목만 MarkdownView 본문 + “동의합니다” 체크박스, 둘 다 필요 시 동시·둘 다 204 완료 시 /admin 복귀 + me 무효화). /auth/me termsRequiresAgreement·privacyRequiresAgreement 플래그 해소 경로 완성. 강제 모델(유예 없음·마이그레이션 0). 임시 레이아웃(handoff ops-reagree 시안 부재 — change-password AuthCard + step2 체크박스 미러). 후속: STORE_MANAGER 재동의 UI(점장 surface 부재) |
미구현 라우트 — “준비 중” placeholder (SPEC #030)
사이드바에는 노출되나 실 기능이 없는 라우트는 빈 화면/404 대신 “준비 중”
placeholder(components/shell/coming-soon.tsx ComingSoon)로 안내한다. 셸
(topbar·sidebar)은 유지되고 본문만 “이 기능은 아직 구현되지 않았습니다” 안내 카드로
채운다 — 로드맵 가시성 확보 + 사용자 혼란 제거. 사이드바 항목은 숨기지 않는다.
/music은 placeholder 해소(SPEC #052). 운영사 음원 카탈로그 UI(목록·제목검색·페이지네이션· 미리듣기·업로드·교체·삭제)가 라이브 — Music · 운영사 카탈로그 UI 참조./libraries도 placeholder 해소(SPEC #053). 운영사 라이브러리 UI(목록·생성·타입필터·상세 곡 목록·이름수정·삭제 + 카탈로그 [라이브러리에 추가])가 라이브 — Library 참조./playlists신설(SPEC #054). 운영사 플레이리스트 UI(목록·소속 본사 필터·생성(본사 dropdown)·상세 라이브러리 담기/제거/순서·이름수정·삭제)가 라이브 — Playlist 참조. 매장 적용 도착(SPEC #055, #054 F1). 매장 상세 개요 탭 “활성 플레이리스트” 섹션 — 본사 PL 1개 적용/해제/조회(단일 활성·공유 모델·hqId 제약·후보 hqId 한정) — Store 상세 참조. 본사 플레이리스트 조회 도착(SPEC #057).apps/space본사 모드/admin/playlists목록·상세 (read-only) — 라이브러리 수·적용 매장 수·라이브러리 목록(position 순). hqId 토큰 주체 스코프 (listHqPlaylists·getHqPlaylist, verifyHqScope). 음악 2계층의 “운영사 편집 · 본사 조회” 대칭이 완성 — HQ Mode 플레이리스트 조회 참조. 본사 라이브러리 조회 도착(SPEC #080).apps/space본사 모드/admin/libraries목록(read-only) — 이름·타입 배지(AI/TRUST)·음원 수·PL 사용 수(본인 본사 PL 한정)·수정 시각. q(이름) + type 필터
- 페이지네이션, 정렬
name ASC서버 고정. 가시 범위는 운영사가 만든 모든 라이브러리(plan/type mismatch 게이트는 후속 #060 F1). 사이드바 “라이브러리” enable — 음악 2계층 본사 조회 라이브러리 층 완성 — HQ Mode 라이브러리 조회 참조. 상세 view·PL 추가 다이얼로그는 후속. 본사 기본 플레이리스트 도착(SPEC #058, F-default 해소). 운영사가 본사당 1개 기본 PL 을 지정/해제 (setDefaultPlaylist/clearDefaultPlaylist·playlist.is_default)하고, 운영사·본사 화면에 read-only/토글 “기본” 배지를 노출한다. 점장 큐(#056)는 매장 활성 PL 부재 시 그 본사 기본 PL 로 fallback (source=DEFAULT·비파괴·조회 시점) — 큐가 기본 동작한다. 플레이리스트 상태 파생 도착(SPEC #060, #054 F3).PlaylistStatus{EMPTY,UNUSED,ACTIVE,FALLBACK}파생값 (DB 저장 없음·derivePlaylistStatus응답 계산)·운영사 응답에appliedStoreCount/status추가·운영사·본사 화면에 read-only 포함 상태 배지(사용 중/기본/미사용/비어있음). MISMATCH(플랜 불일치)만 잔여(#060 F1 — 플랜 게이팅 후속). status 배지의 FALLBACK 은 기존 “기본” 배지와 통합(중복 회피). 점장 player(source안내) 도착(SPEC #064) —/storeClassic player 가 재생 큐를 소비하고source=DEFAULT 시 “기본 재생목록 재생 중” 안내·빈 상태(NO_ACTIVE_PLAYLIST/EMPTY_PLAYLIST)를 노출한다(Store Player Home). 점장 직접 선택 도착(SPEC #129) —/store/playlist에서 점장이 본사 PL 목록 중 매장 활성 PL 을 직접 선택([선택])하거나 본사 기본으로 되돌린다([본사 기본으로]=null, #058 fallback). store.hqId 격리·타 본사 PL 404·plan 위반은 큐(#122)가 필터(Store Active Playlist). 잔여: 임퍼소네이션 편집 토글(#055 F-* · #057 F1F2 · #058 F2F4).
| 라우트 | 사이드바 라벨 | 후속 도메인 SPEC |
|---|---|---|
/billing | 정산 | 정산 도메인 (중기) |
/contracts(협약)는 SPEC #127 로 본사 동의 이력·협약 상태 조회(비-결제) 구현 완료 — placeholder 졸업(features/contracts).getHqConsents(약관/개인정보 동의 이력 + HqStatus + 재동의 필요 여부) 본사 드롭다운 조회. PRD Page 20 의 협약 단가·청구·만료 알림(ContractPlan)은 결제 도메인이라 결제 도입 시 후속(무료 MVP 제외)./users(계정)은 SPEC #032 로 운영자 계정 관리 구현 완료 — placeholder 졸업 (features/operators/management)./stats(통계)는 SPEC #017+#035+#126 으로 운영 통계 전용 페이지 구현 완료 — placeholder 졸업(features/stats). CSV 내보내기 ✅ SPEC #133(지표 snapshot·30일 추이 2종, 클라 사이드). 매출/기간 선택은 후속.
보호 영역 안 잘못된 경로는 (protected)/not-found.tsx 가 셸 유지한 채 “페이지를
찾을 수 없습니다” 안내를 렌더한다.
중기 (베타 출시 직전)
| 항목 | 영역 |
|---|---|
| 음원 카탈로그 (Music · Library · Playlist) — PRD Page 5·6·7·8·9 | BE + FE — 음원 파일 업로드 BE ✅ (#041) — uploadMusic/replaceMusicFile(multipart, 서버 ID3v2.4 태그 재기록 → blob 1회 저장), music 테이블(V15), 식별자 통일(blob key=PK), StoragePort(Local/Azure 어댑터), 감사 MUSIC_UPLOADED·MUSIC_FILE_REPLACED. 음원 조회·소프트삭제 BE ✅ (#042) — listMusic(페이지네이션·제목 ILIKE 검색·created_at DESC, id DESC 최신순·활성만, MusicListResponse{items,page,size,total}·항목 MusicListItem은 audioUrl 제외)·getMusic(상세 MusicResponse)·deleteMusic(204 소프트삭제 deleted_at·blob 유지·감사 MUSIC_DELETED·존재은닉 404). 운영사 카탈로그 UI ✅ (#052) — apps/admin /music 목록(제목·길이·업로드일)·제목검색·페이지네이션(useListMusic client-query)·상단 미리듣기 플레이어(재생 시 getMusic audioUrl)·업로드(브라우저 title/duration 추출 → multipart)·교체·삭제(2-step). 타입(AI/TRUST) 컬럼·필터·업로드 입력은 #059 로 도착(아래) — 무드/BPM·필터 사이드바·일괄작업은 음원 모델 enrich 후속. 라이브러리 도메인 ✅ (#053) — 음원→라이브러리 2계층 중간 층. library·library_music(V16, unique(library,music) 할당 멱등), CRUD + 음원 할당/해제(createLibrary·listLibraries·getLibrary·updateLibrary·deleteLibrary 소프트삭제·listLibraryMusic·addLibraryMusic·removeLibraryMusic). 운영사 UI ✅ — /libraries 목록(이름·타입배지 AI/TRUST 라벨 병기·생성일)·생성·타입필터·상세 곡 목록(페이지네이션)·이름수정·삭제(2-step) + /music 카탈로그 [라이브러리에 추가]. 플레이리스트 도메인 ✅ (#054) — 음원→라이브러리→플레이리스트 2계층 최상위 층. playlist·playlist_library(V17, unique(playlist,library) 담기 멱등·position 순서), CRUD + 라이브러리 담기/제거/순서(createPlaylist·listPlaylists·getPlaylist·updatePlaylist·deletePlaylist 소프트삭제·listPlaylistLibraries·addPlaylistLibrary·removePlaylistLibrary·reorderPlaylistLibraries). 플레이리스트 = 라이브러리 묶음(곡 직접 아님), hqId 소유. 운영사 UI ✅ — /playlists 목록(이름·소속 본사명·생성일)·본사 필터·생성(본사 dropdown + 이름)·상세 라이브러리 담기/제거/순서(위·아래 이동)·이름수정·삭제(2-step). 본사 기본 PL ✅ (#058, F-default) — playlist.is_default(V19, partial unique index 본사당 1개), setDefaultPlaylist(PUT, 원자적 교체)·clearDefaultPlaylist(DELETE, 멱등) + PlaylistResponse·PlaylistListItem·HqPlaylist* 에 isDefault. 점장 큐(#056)는 매장 활성 PL 부재 시 본사 기본 PL fallback(StorePlaybackQueueResponse.source = ACTIVE/DEFAULT/NONE·비파괴·조회 시점). 운영사 UI ✅ — /playlists 목록·상세 [기본으로 지정/해제] 토글 + 기본 배지(PlaylistDefaultDialog, 1-step). 본사 UI ✅ — apps/space 목록·상세 read-only “기본” 배지. 매장 적용 ✅ (#055, #054 F1) — 음악 2계층의 매장 적용 층. store.active_playlist_id(V18, nullable FK·partial index·단일 활성·공유 모델), getStoreActivePlaylist·setStoreActivePlaylist(hqId 일치 검증·409 STORE_PLAYLIST_HQ_MISMATCH)·clearStoreActivePlaylist(멱등 204) + PL 소프트삭제 시 참조 매장 자동 해제(D7). 운영사 UI ✅ — 매장 상세 개요 탭 “활성 플레이리스트” 섹션(조회·적용/변경(후보 hqId 한정)·해제). 본사 플레이리스트 조회 ✅ (#057) — apps/space 본사 모드 /admin/playlists 목록·상세(read-only). listHqPlaylists(HqPlaylistListResponse{items,page,size,total}·항목 이름·라이브러리 수·적용 매장 수·updatedAt)·getHqPlaylist(HqPlaylistDetailResponse 개요 + 라이브러리 목록 position 순). hqId 토큰 주체 스코프(verifyHqScope·요청 파라미터 없음·타 본사 PL 404 은닉). 음악 2계층 “운영사 편집 · 본사 조회” 대칭 완성. 음원 타입 enforcement ✅ (#059, #053 F1 해소) — music.music_source(AI/TRUST, V20·업로드 시 지정·불변), uploadMusic 필수 param·listMusic musicSource? 필터·MusicResponse/MusicListItem 에 musicSource. addLibraryMusic 시 음원 타입 ≠ 라이브러리 타입이면 400 LIBRARY_TYPE_MISMATCH(위반 musicId 를 fields.violatingMusicIds 노출·전체 reject). 운영사 UI ✅ — /music 업로드 타입 라디오(필수)·행 타입 배지·툴바 타입 필터 + [라이브러리에 추가] picker 를 음원 타입과 일치하는 라이브러리만 후보로 좁힘·mismatch 메시지 매핑. 플레이리스트 상태 파생 ✅ (#060, #054 F3) — PlaylistStatus{EMPTY,UNUSED,ACTIVE,FALLBACK} 파생값(DB 저장 없음·derivePlaylistStatus(libraryCount,appliedStoreCount,isDefault) 응답 계산·배타 우선순위 EMPTY>FALLBACK>ACTIVE>UNUSED) + 운영사 PlaylistResponse/PlaylistListItem 에 appliedStoreCount/status 추가·본사 HqPlaylistListItem/HqPlaylistDetailResponse 에 status 추가. 운영사 UI ✅ — /playlists 목록·상세 상태 배지(사용 중/기본/미사용/비어있음)·적용 매장 수 컬럼. 본사 UI ✅ — apps/space 목록·상세 read-only 상태 배지. FALLBACK status 는 기존 “기본” 배지와 통합(중복 회피·EMPTY 우선 시 병치). 제외: AI 커버·Cyanite 분석(별 도메인). 잔여: 음원 모델 enrich(무드·BPM·태그·아티스트, 시안 필터/컬럼 전제) · hard-purge 배치(만료 blob+row 영구삭제) · 플랜 게이팅 첫 구현 ✅ (#060 F1 — SPEC #122) — 점장 재생 큐에서 plan 위반 음원 서버 제외(effective plan store.plan ?? hq.plan vs library.libraryType, plan null=무제약, 전부 위반→EMPTY_PLAYLIST, 활성·기본 PL 동일 필터·BE-only·마이그레이션 0). 잔여(MISMATCH 후속): PL 상태 배지로서의 MISMATCH(운영사/본사 편집 화면 plan 위반 경고)·활성 PL 전부 위반 시 기본 PL 자동 폴백·전용 reason code(PLAN_FILTERED) 차등 안내. · 점장 player UI(source 안내) ✅ (#064 — /store Classic player)·점장 직접 선택·임퍼소네이션 편집 토글(#055 F-* · #057 F1 |
| 정산 (ContractPlan · BillingKey · Invoice · TaxInvoice) — Page 10·11·12 | BE + FE |
| CS 티켓 — Page 13·14 | BE + FE — v1 ✅ (#027) — 목록(상태/우선순위/담당자/본사 필터·생성 다이얼로그)·상세(채팅형 코멘트 스레드 REPLY/INTERNAL·상태머신 전이·담당자 배정/해제). store 식별 ✅ (#113) — 운영자 목록·상세에 출처(매장명 storeName/본사명) + 분류(category) 배지 노출 + 목록 category 필터 select(보조로 storeId URL 진입). read-side LEFT JOIN store + DTO 필드(마이그레이션 0). category 라벨은 점장 support 와 동일 매핑(ticket-meta.ts CATEGORY_META). 점장 CS end-to-end 마감(점장 작성 → 운영자 식별·트리아지). 임시 레이아웃(전용 시안 부재). 후속: 본사 백오피스 category 표면화·첨부·SLA·고객 알림·storeId 검색 picker |
SPEC #119 완료 — 외부 알림 인프라(이메일·푸시) 없이 react-query refetchInterval 폴링으로 각 페르소나 “새 일” 시그널을 카운트/도트 배지로 표면화. 신규 테이블 0(상태 기반 actionable count 재사용 + CS “새 답변”만 클라 localStorage last-seen). F1 운영자 OpsSidebar /tickets 미해결 CS 카운트 pill(AdminStatsResponse.tickets.open+inProgress 재사용·BE 0·dedup). F3 본사 대시보드 “오늘 송출 미도달 매장” 카드(getHqUndeliveredToday·오늘 KST PENDING distinct·D4). F4 본사 HQSidebar /admin/support 새 답변 dot(getHqSupportUnreadSignal.latestOperatorReplyAt vs localStorage lastSeen·D2). F5 점장 player 헤더 [고객지원] dot(getStoreSupportUnreadSignal·F4 헬퍼 재사용). 폴링 60초·refetchIntervalInBackground:false(탭 비활성 중단)·focus 복귀 즉시. 배지 atom-grounded(design-debt 등재). last-seen 헬퍼 apps/space/src/lib/support-last-seen.ts(localStorage 실패 보수 폴백). F2(점장 안내방송 배지) 생략(D3 — player 가 PENDING 이미 자동 소비). 범위 밖(후속): 풀 notification 도메인(per-item read 테이블·다기기 동기화)·이메일/푸시·ack 실시간 SSE. | |
감사 로그 (TenantAuditLog) — Page 15 | BE + FE — 임퍼소네이션 세션 단위 ✅ (#025) · 일반 OPERATOR 액션 감사 ✅ (#026 — 공통 OperatorAuditLog: HQ 정지/복구·점장 발급·약관/개인정보 게시·HQ 온보딩) · CSV export 3종 완결 ✅ (운영자 액션 #048 · 본사 송출 #070 · 임퍼소네이션 #074 — 모두 같은 패턴: text/csv + UTF-8 BOM + RFC4180·RFC5987 filename*·X-Export-Truncated 상한 50,000·공용 AuditCsvWriter CSV injection 방어·공용 helper csv-export.ts downloadCsvFromBackend 우회·해당 페이지 PageHeader [CSV 내보내기] 버튼·잘림/실패 Banner). 후속: 세션 내 액션 타임라인(HQ 모드 Surface 11)·다른 도메인 액션(음원 업로드/교체/삭제 ✅ #041·#042, 정산/CS 잔여)·PDF 내보내기·진행 중 세션 실시간 폴링 |
| 통계 — Page 16 | BE + FE — 운영 지표 v1 ✅ (#017·#035·#126) — /stats 분포 그리드(매장·계정·CS·본사 유형/상태) + 30일 가입 추이(#017·#035), #126 확장: 송출 운영 지표(status 분포 4종·도달률 FE 파생·최근 7일 송출·30일 송출 추이)·매장 audit 활동(7일)·CS 티켓 해결률(FE 파생) + 대시보드 본문 “최근 방송” 송출 요약 실데이터화. AdminStatsResponse 확장(마이그레이션 0·신규 endpoint 0). 라벨 “운영 통계”(무료 MVP — 매출 미언급)(Stats·Dashboard). CSV 내보내기 ✅ #133(FR-16.8 — 지표 snapshot 분류,지표,값 + 30일 추이 일자,지표,건수 long 포맷, 클라 사이드 직렬화·BOM·RFC4180·BE endpoint 0). 후속: 매출/MRR/type별 매출/churn/평균재생시간(결제·재생로그 도메인 부재)·기간 선택(FR-16.1)·PNG export·CM송 재생 건수(재생 카운트 부재)·장애 통계(incident 부재) |
| 운영자 계정 관리 — Page 17 | BE + FE — ✅ (#032 · #043) — /users 목록(이메일·이름·상태·임시비번·마지막 로그인 KST·가입 KST)·초대·비밀번호 재설정·정지/복구/회수(soft-delete). self-guard·마지막 활성 운영자 보호·세션 즉시 무효화. #043: 서버사이드 검색(q: email·name)·status 필터·페이지네이션(envelope {items,page,size,total}). 임시 레이아웃(전용 시안 부재 — ops-users 제안). 후속: 역할(권한 등급) |
| 설정 7 서브 — Page 18 | FE — 스캐폴딩 ✅ (#128) · 18-1 내실 ✅ (#130) · 18-3 내실 ✅ (#132) · 4 서브 내실 후속 |
| 협약 관리 — Page 20 | BE + FE |
Surface 11 (본사 모드)
| 항목 | 비고 |
|---|---|
/admin/* 라우트 도입 | apps/admin (SPEC #015·#016) — HQ_MANAGER 직접 로그인 시 본사 모드 셸 진입 |
| HqShell (#015) | |
| 인프라 + 배너 (#013·#015) | |
SPEC #049 완료 — getHqMe(GET /api/v1/hq/me, HqMeResponse). /api/v1/hq/** → hasRole("HQ_MANAGER") 1차 경계 + PrincipalScopeGuard claim↔DB 재검증. 본사 셸 본사명·요약 표시 토대 | |
SPEC #051 완료 — getHqDashboard(GET /api/v1/hq/dashboard, HqDashboardResponse). apps/space /admin 산하 매장 상태별 집계(총수·ACTIVE·INACTIVE·SUSPENDED·폐점) 카드. 송출·정산 카드는 “준비 중”(방송·billing 도메인 미도착, §F1) | |
SPEC #051 완료 — listHqStores(GET /api/v1/hq/stores, HqStoreListResponse). apps/space /admin/stores 검색(매장명·주소)·status·type 필터·페이지네이션(공용 ListToolbar/ListPagination). read-only — mutating(매장 등록·점장 발급·상태변경)은 후속(§F2) | |
SPEC #084 + #105 + #106 완료 — read: getHqStore(GET /api/v1/hq/stores/{id}, HqStoreDetailResponse). FE 5 섹션(개요/점장 정보/활성 PL/운영 상태/메타) + 매장 목록 매장명 셀 Link 진입점. 빈 상태 — storeManager==null “점장 계정 미발급” · activePlaylist==null “활성 PL 없음(본사 기본 PL fallback 가능)”. 타 본사 매장·미존재 404 STORE_NOT_FOUND 존재 은닉. 편집 ✅ SPEC #105 — 신규 endpoint updateHqStore(PATCH /api/v1/hq/stores/{id}, HQ_MANAGER, UpdateHqStoreRequest{name·address·managerName·managerEmail·managerPhone} 5 필드 JsonNullable<String> — 키 부재=미변경 / 키+null=clear / 키+value=set, name 만 비-nullable). 응답 200 HqStoreDetailResponse(read-back, D7). 검증 D8: name 1..50 비-blank · address ≤200 · managerName ≤50 · managerEmail @Email+≤255 · managerPhone ≤30. 위반 시 400 HQ_STORE_INVALID_FIELD. 변경된 필드만 audit 1행 기록(HqAuditAction.HQ_STORE_UPDATED + HqAuditTargetType.STORE, detail.changedFields + before/after partial 스냅샷). 변경 0 = no-op 200(audit row X). FE: 매장 정보 섹션 헤더 [편집] 토글 → <HqStoreEditForm> 인라인(5 input + 검증 미러 + [취소]/[저장]) → 성공 시 detail invalidate + read 모드 복귀 + 1회성 success Banner. orval 이 JsonNullable partial 의미를 표현 못해 generated UpdateHqStoreRequest 가 5 필드 required 로 떨어지는데, FE 는 Partial<> payload 를 cast 로 우회(D2 의미 runtime 보존). 9 신규 테스트 케이스(편집 토글·검증·payload·에러 매핑). 운영사 매장 상세(#036) 패턴 미러 + 본사 PL 상세(#057) 헤더 스타일 일관. 신규 등록 ✅ SPEC #106 (#084 F2 마감) — 신규 endpoint createHqStore(POST /api/v1/hq/stores, HQ_MANAGER, CreateHqStoreRequest{name·address?·managerName?·managerEmail?·managerPhone?} 단순 nullable — JsonNullable 아님, null/생략 = 미입력 동일). type 은 본사 유형 자동 결정(D2 — INDEPENDENT 가상 본사 → INDEPENDENT, 그 외 → FRANCHISE. DIRECT 는 운영자 직영 표시라 본사 폼에 없음). 점장 계정 발급은 분리(D3, 후속 F5). 검증 D4 동일 5 필드 + name 1..50 비-blank 필수. 위반 400 HQ_STORE_INVALID_FIELD · 정지(SUSPENDED) 본사 등록 거부 403 AUTH_HQ_SUSPENDED(D5, login 차단 first line + service 가드 second line) · 동일 본사 내 name 중복 허용(분점 패턴, D6) · 응답 201 HqStoreDetailResponse(read-back, D1 — FE invalidate 1회 + 새 매장 상세로 push 직행). audit HqAuditAction.HQ_STORE_CREATED 신규 액션(총 9종) + HqAuditTargetType.STORE 재사용, detail {name, type, address, managerName} partial 스냅샷. FE: 매장 목록 헤더 [+ 매장 등록] 진입점(hq-store-list-create) → /admin/stores/new 단일 단계 폼(hq-store-create-form.tsx, 5 input + Field 기반 a11y helper id 자동 부여 + aria-invalid + Banner role=alert + [취소(router.back)]/[등록]) → 성공 시 ["/api/v1/hq/stores"] prefix invalidate + 응답 id 로 /admin/stores/{id} push. CM송 등록 #093 헤더 idiom + 매장 편집 #105 form idiom 미러(atom-grounded, D8 시안 부재). audit 양 surface(apps/space hq-audit-client.tsx + apps/admin audit-hq-client.tsx) 모두 “매장 등록”(info) 라벨 추가 — 전수 Record<HqAuditItemAction> 강제로 컴파일 시점 누락 노출. 7 신규 테스트 케이스(빈 name disabled·name clear disabled·email 형식 helper+aria-describedby·trim+null payload+detail push·5 필드 + endpoint URL 검증·400 매핑·403 SUSPENDED 매핑). docs 5종 갱신(endpoints·dtos·mode-dashboard-stores·audit·홈 index). 잔여 후속(#084 라인): managerName/managerEmail 노출(편집 폼 초기값 정합)HqStoreDetailResponse 에 managerName/managerEmail additive 2필드 + store 컬럼 매핑 — 마이그레이션 0. FE: 개요 카드 담당자 행 노출 + 편집 폼 5필드 prefill 정합, store 연락 컬럼 ≠ 점장 계정 storeManager. docs 3종 갱신 dtos·store-detail·mode-dashboard-stores) · F7 시안 도착. 새 env var 0 · 마이그레이션 0. | |
SPEC #057·#058·#060 완료 — listHqPlaylists·getHqPlaylist. apps/space /admin/playlists 목록·상세 read-only(기본 PL 배지 #058 + 파생 상태 4종 배지 #060). 편집은 운영사 전용 | |
SPEC #061 + #062 + #063 완료 — createHqTtsAnnouncement(합성)·listHqTtsAnnouncements·getHqTtsAnnouncement·updateHqTtsAnnouncement(#062)·deleteHqTtsAnnouncement·listHqTtsVoices(#063). apps/space /admin/announcements 텍스트+voice(5종)+톤 프리셋(6종, voice 연동) 합성(Typecast→Azure blob)·목록·재생(<audio>)·수정·삭제. 수정(#062): 행 [수정] → create/edit 겸용 다이얼로그(상세 fetch 로 초기값 채움)·조건부 재합성(text/voice/톤 변경 시 재합성·title 만 변경 시 skip, §5-1·판정 BE)·재생 캐시버스터 ?v={updatedAt}(blob 덮어쓰기 stale 회피). 톤 프리셋(#063): 톤 select 가 voice 에 연동(선택 voice 의 허용 톤만, 미지원 시 NORMAL 리셋)·목록/상세 비-NORMAL 톤 배지·미지원 톤 400 TTS_INVALID_TONE_PRESET. 콘텐츠 생성·수정·보관·톤만 — 매장 송출·점장 큐 삽입·분 슬롯 스케줄·미리듣기 게이트는 후속. env TYPECAST_API_TOKEN(미설정 시 합성 503). 프로덕션 활성화 ✅ SPEC #134 — 실 토큰 round-trip 으로 응답 계약 검증 완료(파싱 코드 변경 0)·운영자 설정 [TTS 연결 확인] smoke-test(runTtsSmokeTest, 부작용 0)·503/502 구분 안내·TYPECAST_API_TOKEN Render 주입 시 동작 | |
송출 슬라이스 완료 — dispatchHqTtsAnnouncement(POST /api/v1/hq/announcements/{id}/dispatch, DispatchRequest{target:ALL} → 매장 fan-out DispatchResponse{dispatchedCount,dispatchIds}) + 점장 수신 listStorePendingAnnouncements(20초 폴링)·ackStoreAnnouncement(204 멱등). apps/space /admin/announcements 행 [송출](전체 매장)→ 점장 player 가 곡 끝에 1회 삽입 재생→ack→음악 복귀. 본사→매장 재생 dead-end 폐쇄. announcement_dispatch 테이블(V23)·DispatchStatus(PENDING/PLAYED). 잔여 후속: | |
송출 이력 슬라이스 완료 — listHqTtsAnnouncementDispatches(GET /api/v1/hq/announcements/{id}/dispatches, query page?·size? → 200 DispatchHistoryResponse{items,page,size,total,aggregate}, 행=DispatchHistoryItem{dispatchId·storeId·storeName·status(PENDING/PLAYED)·createdAt·playedAt?·actorEmail?·actorRole?·impersonatedByEmail?}(#071 actor 3 필드 추가), aggregate=DispatchHistoryAggregate{total,played,pending} 페이지 무관 전체 카운트, 정렬 created_at DESC, id DESC 서버 고정). apps/space /admin/announcements 행 [이력] 또는 송출 결과 배너 [이력 보기] → DispatchHistoryDialog(집계 헤더 + 매장 행 표 5컬럼 + 페이지네이션, read-only). dispatch 백본(announcement_dispatch, V23) 의 row 를 announcement scope 로 조회. 중복 송출은 row 단위로 전부 노출(매장 그룹핑은 후속 F). 타 본사·미존재·삭제·STORE_BROADCAST 출처 404 TTS_ANNOUNCEMENT_NOT_FOUND 존재 은닉. V26 인덱스 idx_announcement_dispatch_history(announcement_id, created_at DESC, id DESC). 본사→매장 피드백 루프 폐쇄. 잔여 후속: 매장 N곳) · 매장 그룹핑 토글 view(매장당 collapse 요약) · CANCELED 추가)/api/v1/hq/dispatches/{id}/cancel · 점장 player 자동 제외 · audit 5종 확장) · 점장 ack 알림(웹소켓·SSE)·이력 CSV export·operator audit 통합 · | |
SPEC #065 D3 후속 마감 — DispatchHistoryAggregate 에 distinctStoreCount: Long 추가(COUNT(DISTINCT d.storeId), 매장 0건이면 0). BE 쿼리·projection·DTO 비파괴 확장(HqTtsAnnouncementQueryService.listDispatches 가 그대로 전달). FE DispatchHistoryDialog 헤더 chip 3종 → 4종 — 총 N건 · 재생 N · 대기 N · 매장 N곳. 같은 매장 다회 송출은 한 번만 카운트해 본사가 매장 단위 도달 범위를 한 눈에 인지(중복 누적 row 의 total 만으로는 매장 수 직관 0). 매장 0건(송출 전)도 매장 0곳 으로 노출 — 본문 empty state 와 보완. 기존 chip 스타일(폰트·spacing·테두리·색) 그대로 미러(시안 발명 0). BE+FE 작은 슬라이스·비파괴·새 env var 0. 매장별 그룹핑 토글 view·매장당 요약(최근 송출 시각·총 횟수)은 별도 endpoint F. | |
FE-only 슬라이스 — DispatchHistoryDialog 헤더 chip 줄 아래에 radiogroup 행 단위/매장 단위(hq-announcement-history-group-toggle, 시안 부재 atom-grounded — STORES 모드 radiogroup 패턴 미러). 기본 OFF — 기존 dispatch row 단위 표 회귀 0. ON 시 현재 페이지 row 를 storeId 로 묶어 매장 단위 1행 노출(매장명·송출 횟수·최근 송출 시각·최근 상태·펼치기 chevron, 5컬럼). 그룹 내 row 는 createdAt DESC 정렬해 “최근” 결정적, 매장 단위 정렬은 그룹 내 가장 최근 createdAt DESC(최근 송출 매장이 위). 매장 row 클릭 또는 chevron → 그 매장의 dispatch row 가 들여쓰기 sub-row 로 노출(기존 DispatchHistoryRow nested prop 분기 — 컴포넌트 추출 없이 inline). 매장 단위 row 자체엔 [재송출]/[취소] 노출 X(§D6 — 의미 모호), 펼친 dispatch row 에서만 노출(기존 그대로). 다이얼로그 닫힘/재오픈 시 토글 OFF + 펼친 매장 집합 비움(useEffect([target]) 단일 진실원). BE 신규 endpoint 0 · 새 컴포넌트 추출 0 · 시안 부재(F4 정식 시안 도착 시 정합 교체). F: | |
#089 F2 마감 + F1 highlight — 그룹핑 view 한정 매장명 검색. FE-only 슬라이스 · BE 변경 0. DispatchHistoryDialog 의 매장 단위 그룹핑 ON 모드에서만 토글 아래 줄에 컴팩트 <input type="search">(hq-announcement-history-store-search·너비 max-w-xs·placeholder 매장명 검색) 추가. 입력값이 비어있으면 기존 노출 그대로. 비어있지 않으면 storeName.toLowerCase().includes(query.trim().toLowerCase()) 로 grouping 이전에 row 필터(같은 storeId row 가 분산되지 않게 — 현재 페이지 한정). 필터 후 0 매장이면 표 자리에 안내 검색 결과가 없습니다.(hq-announcement-history-store-search-empty). 다이얼로그 닫힘 · 그룹핑 OFF 전환 · 페이지 이동 시 storeQuery 리셋(useEffect[target]·[groupByStore]·[page] 단일 진실원). 정규식 사용 안 함 · 다중 토큰 사용 안 함 — 단순 substring. 행 단위 모드에서는 input 자체가 mount 안 됨 — 회귀 0. atom-grounded(시안 부재 — STORES 모드 매장 검색 input idiom 미러). F1 highlight ✅ — 노출 매장의 매장명에서 검색어 substring 을 <mark>(hq-announcement-history-store-search-highlight · bg-yellow-200/70 라이트 · bg-yellow-400/40 다크) 으로 감싸 시각 강조. 다회 등장 모두 표시. 빈 검색어면 plain text. 잔여 F2 매장 단위 BE 페이지네이션(#089 F3) 후속. | |
SPEC #065 F(재송출/취소) 후속 일부 마감 — 재송출만 (취소 F1 ✅ SPEC #077 도착) — DispatchHistoryDialog 행위자 셀 오른쪽 끝에 inline [재송출] 텍스트 버튼 + lucide Send 아이콘(hq-announcement-redispatch-row-{dispatchId}, 새 컬럼 추가 X). PENDING/PLAYED/CANCELED 모두 활성·V28 이전 row(actorEmail null)도 활성·폐점 매장 row 도 활성. 클릭 → 본문 표 위에 inline confirm strip 노출 — ”**{매장명}**에 다시 송출하시겠습니까? [재송출] [취소]“(새 모달 컴포넌트 추출 X — strip 으로 시각 무게 최소화). strip [재송출] → useDispatchHqTtsAnnouncement.mutate({ id: target.id, data: { target: "STORES", storeIds: [storeId] } }) (BE 변경 0 · 새 endpoint 0 · 새 audit 0 — 기존 dispatch hook 이 audit 자체 기록). mutating 중 strip [재송출] disabled + aria-busy + “재송출 중…” + 행 버튼도 disabled + Loader2 스피너. 성공 → 같은 announcement scope 의 history query 접두를 queryClient.invalidateQueries({ queryKey: getListHqTtsAnnouncementDispatchesQueryKey(target.id) }) 로 무효화 → 새 PENDING row 가 표에 등장하고 헤더 chip(total · pending · distinctStoreCount) 자동 갱신. 실패 → strip 자리를 Banner tone="danger"(hq-announcement-redispatch-error)로 교체 — “재송출에 실패했습니다. 잠시 후 다시 시도해 주세요”(엣지 DISPATCH_STORE_NOT_FOUND 도 같은 일반 메시지 흡수 · §D9). strip [취소] 또는 다이얼로그 닫힘(target → null) 시 strip dismiss + 선택 매장 state 리셋(useEffect([target]) 단일 진실원). FE-only · BE 변경 0 · 새 endpoint 0 · 새 audit 0 · 새 env var 0 · 시안 발명 X(기존 admin 영역 inline 액션·strip 패턴 미러). | |
SPEC #065 F · #076 F1 후속 마감 — 본사 송출 취소 — BE+FE 비파괴 슬라이스. BE: DispatchStatus CANCELED 추가(enum 값만 — 마이그레이션 0) + 신규 endpoint PATCH /api/v1/hq/dispatches/{id}/cancel(cancelHqDispatch, HQ_MANAGER-only, 204 No Content, body 없음). 원자적 조건부 UPDATE(UPDATE announcement_dispatch SET status='CANCELED', updatedAt=:now WHERE id=:id AND hqId=:hqId AND status='PENDING') — 1행 영향=204 + 같은 트랜잭션 audit 1건(HqAuditAction.HQ_ANNOUNCEMENT_DISPATCH_CANCELED 5종 확장) · 0행=404 DISPATCH_NOT_FOUND(이미 PLAYED/CANCELED · 동시 취소 · 미존재 · 타 본사 — 모두 은닉, 점장 ack 멱등 패턴 미러). audit INSERT 실패 시 액션도 함께 롤백(원자성, #067 패턴). @Modifying JPQL 의 @LastModifiedDate 우회를 위해 updatedAt=:now 명시(backend.md #4). 점장 player 자동 제외 — listStorePendingAnnouncements 가 이미 WHERE status='PENDING' 필터(코드 변경 0). PLAYED 는 종착 — 취소 불가(시간 되감기 X). FE: DispatchHistoryDialog 행 [재송출] 옆 inline [취소] 버튼 + lucide Ban 아이콘(hq-announcement-cancel-row-{dispatchId}) — PENDING 행만 노출(PLAYED·CANCELED 미노출). 같은 strip 영역을 actionMode: 'redispatch' | 'cancel' state 로 분기(strip 자리 1개 공유 — 시각 무게 최소화). cancel strip = “{매장명} 송출을 취소하시겠습니까? [취소 확정] [닫기]“(hq-announcement-cancel-strip, [취소 확정] = danger 톤). strip [취소 확정] → useCancelHqDispatch.mutate({ id: dispatchId }) → 성공 시 history query invalidate → row status CANCELED + 헤더 chip pending -1(distinctStoreCount 무영향). 404 → “이미 재생됐거나 취소된 송출입니다” Banner danger · 그 외 → “송출 취소에 실패했습니다. 잠시 후 다시 시도해 주세요”. strip [닫기] 또는 다이얼로그 닫힘 시 mode·선택·에러 모두 리셋. CANCELED row 의 StatusPill 은 muted 톤 “취소됨” — Record<DispatchHistoryItemStatus,...> 전수 강제(누락 컴파일 에러). /admin/audit ACTION_LABELS·ACTION_TONES 도 5종으로 자동 확장(“송출 취소”/warn). 새 env var 0 · 시안 발명 X(기존 strip 패턴 미러). | |
#076 F2 + #077 F1 마감 — 다중 행 일괄 [선택 재송출]/[선택 취소] — FE-only 슬라이스, BE 변경 0. DispatchHistoryDialog 행 단위 모드에서만 표 첫 셀에 체크박스 컬럼(헤더 = 현재 페이지 전체 선택/해제 hq-announcement-history-select-all · 행 = 개별 hq-announcement-history-select-row-{dispatchId}) + 헤더 chip 줄 아래 일괄 액션 영역(hq-announcement-history-bulk-actions) “N개 선택” 카운터 + [선택 재송출](primary, 선택 > 0 활성) + [선택 취소](danger, cancellable PENDING/SCHEDULED > 0 활성). [선택 재송출] 확정 → unique storeIds 추출(같은 매장 다수 row 선택은 1회 dedupe) → useDispatchHqTtsAnnouncement.mutateAsync({ target: 'STORES', storeIds }) 1회 호출 → success banner 재송출 완료 (N매장). [선택 취소] 확정 → cancellable row 만 useCancelHqDispatch.mutateAsync({ id }) 각각 Promise.allSettled([...]) 다중 호출 → fulfilled/rejected 카운트로 banner 분기(취소 완료 N개 success / 부분 실패 취소 완료 N개 / 실패 M개 danger). 진행 중엔 두 버튼 disabled + 처리 중... 보조 텍스트(hq-announcement-history-bulk-progress). 그룹핑 토글 ON 모드(#089) — 당시엔 일괄 액션 숨김(§D9), SPEC #125 가 이 숨김을 해제(아래 행). 다이얼로그 닫힘·페이지 이동·그룹핑 모드 전환 시 선택·결과 banner 모두 리셋(useEffect[target]·useEffect[page]·useEffect[groupByStore] 단일 진실원). 기존 단일 행 inline [재송출]/[취소] 액션 그대로 — 회귀 0. atom-grounded — 시안 부재(기존 strip 패턴 미러). 잔여 후속: | |
#096 행단위 로직을 매장 그룹 단위로 미러 — #096 D9 “그룹 모드 숨김” 해제 — FE-only 슬라이스, BE 변경 0. DispatchHistoryDialog 의 매장 단위 그룹핑(group) 모드에도 일괄 [선택 재송출]/[선택 취소]를 추가해 양 모드 모두 일괄 액션을 노출한다. 그룹 모드 표 첫 셀에 매장 단위 체크박스(헤더 = 검색 필터 통과 전체 매장 선택/해제 hq-announcement-history-select-all · 매장 hq-announcement-history-select-store-{storeId}, 일부만 선택된 매장은 indeterminate, row onClick(펼치기)과 stopPropagation 분리) + 카운터 “N개 매장 선택”. 선택 단일 진실원은 행 모드와 동일하게 selectedDispatchIds(dispatchId Set) — 매장 체크박스는 그 매장 그룹의 dispatchId 전부를 한꺼번에 토글한다. 이로써 재송출 unique storeIds dedupe·취소 “매장 내 cancellable(PENDING/SCHEDULED) 전부”가 #096 handleBulkRedispatchConfirm/handleBulkCancelConfirm 핸들러·뮤테이션 재사용으로 자동 정합(새 핸들러 0). 선택 가능 집합은 모드별로 다름 — 행=현재 페이지 전체 row, 그룹=검색(#098) 필터 통과 그룹의 row. confirm strip 문구는 모드 분기(N개 매장에 재송출 / 선택 매장 N곳 중 취소 가능 M개). 모드 전환(행↔그룹)·검색·페이지 이동·다이얼로그 닫힘 시 선택·strip·결과 banner 모두 단일 진실원 리셋(§D3 — useEffect[groupByStore] 가 양방향 리셋으로 확장). 행 단위 #096·그룹핑 view #089·매장명 검색 #098·단일 inline 액션 전부 회귀 0(테스트 고정). atom-grounded — 시안 부재(기존 strip·checkbox 패턴 미러). 잔여 후속: BE bulk endpoint(#089 F2 — 클라 다중 호출 → 한 트랜잭션) · 정식 시안(#089 F4). | |
SPEC #065 F1 마감 — announcement_dispatch.audit_id uuid NULL FK → hq_audit_log(id) ON DELETE SET NULL 컬럼 추가(V28) + 인덱스 idx_announcement_dispatch_audit_id(audit_id). HqAnnouncementDispatchService.dispatch 가 audit row INSERT 후 같은 트랜잭션에서 fan-out dispatch row 의 auditId 를 set(원자성, #067 패턴). findHistoryByAnnouncementId projection 이 LEFT JOIN hq_audit_log a ON a.id = d.audit_id AND a.hq_id = :hqId(타 본사 actor 누출 0) 로 actorEmail·actorRole(HqAuditActorRole)·impersonatedByEmail 3 필드를 평탄 노출. DispatchHistoryItem 에 같은 3 필드 추가(모두 nullable — V28 이전 row 호환). apps/space DispatchHistoryDialog 테이블이 5번째 “행위자” 컬럼으로 확장 — 메인 줄 actorEmail(font-mono), actorRole === "OPERATOR_IMPERSONATING" 이면 보조 줄에 ↩ {impersonatedByEmail} + “운영자 위장” pill 배지(/admin/audit HqAuditRow 패턴 그대로 미러 — 별도 컴포넌트 추출 없이 inline). V28 이전 row 는 메인 셀에 — 폴백. 백필 안 함(announcement_id + occurred_at 범위 백필이 부정확). 다이얼로그 폭 720→820px. 비파괴 변경·새 env var 0. /admin/audit 와 같은 audit row 가 이력 다이얼로그에도 동일하게 노출돼 본사→매장 피드백 루프의 actor 추적성이 완결. | |
list 요약 슬라이스 완료 — listHqTtsAnnouncements 응답 TtsAnnouncementListItem 행에 dispatchCount: Long(중복 송출 포함 누적 송출 row 수, 0=미송출)·lastDispatchedAt: Instant?(MAX(announcement_dispatch.created_at), null=미송출) 두 필드 추가. BE 는 LEFT JOIN announcement_dispatch d ON d.announcement_id = t.id AND d.hq_id = t.hq_id + GROUP BY 평탄 projection 한 쿼리(N+1·round-trip 회피·D2). V26 인덱스 idx_announcement_dispatch_history(announcement_id, created_at DESC, id DESC) 재활용(D3 — 추가 인덱스/마이그레이션 0). 정렬·페이징·검색·source='HQ' 격리는 기존 그대로(D4·D10). apps/space /admin/announcements 행에 “송출 횟수”(우측정렬 tabular-nums, 0이면 —)·“마지막 송출”(formatKstDateTime, null이면 —) 두 컬럼이 액션 직전에 추가돼 본사가 이력 다이얼로그 진입을 가볍게 결정한다. 고유 매장 수가 아니라 dispatch row 수(중복 포함) — 매장 그룹핑은 별도 후속(F). 상세 DTO 는 변경 없음(D12 — 이력 endpoint 가 더 풍부한 시그널 제공). 비파괴 변경. | |
#066 후속 마감 — 마지막 송출 1회 호출의 fan-out distinct 매장 수. TtsAnnouncementListItem 에 lastDispatchStoreCount: Int? 추가(누적 아닌 마지막 1회만·미송출 null). BE: LEFT JOIN LATERAL last_call (LIMIT 1) 로 마지막 dispatch row 의 (audit_id, created_at) 을 announcement 당 1회만 산출(dispatch LEFT JOIN 보다 앞에 두어 row 증폭 전 평가 보장 → planner memoize 의존성 제거), scalar subquery 가 audit_id 그룹(V28+ 호출 단위)으로 distinct store_id 카운트하고 legacy(audit_id NULL) 는 created_at 그룹 fallback. CAST(COUNT(DISTINCT ...) AS int) 명시 캐스트(JPA Int 매핑 안전). 같은 ms tick 동률 호출 시 LIMIT 1 tiebreak 약하지만 audit_id 그룹핑 덕에 두 호출 fan-out 이 섞이지 않고 한 호출로 수렴. 인덱스 추가 0·마이그레이션 0(V26·V28 재활용). FE: /admin/announcements “마지막 송출” 셀 같은 자리에 한 단계 작은 보조 줄 (N매장) 표기. 미송출 또는 legacy lastDispatchStoreCount=null 이면 표시 없음(noise 차단). 매장 그룹핑·이력 누적 매장 수는 별도 SPEC. 비파괴 변경. | |
SPEC #067 F5 마감 — 신규 exportHqAuditDispatches(GET /api/v1/hq/audit/dispatches/export, HQ_MANAGER) → 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename="hq-audit-{yyyyMMdd-HHmmss}.csv"; filename*=UTF-8''...(RFC5987 병기·한글 파일명 대응) + `X-Export-Truncated: true | |
SPEC #067 F9 마감 — 신규 listHqAuditActors(GET /api/v1/hq/audit/actors, HQ_MANAGER) → 200 HqAuditActorListResponse{items: HqAuditActorOption[]}(envelope 없음·상한 200) · 행 HqAuditActorOption{accountId·email·role(HqAuditActorOptionRole HQ_MANAGER/OPERATOR_IMPERSONATING)·occurrenceCount} · 정렬 occurrenceCount DESC, email ASC 서버 고정. 쿼리=hq_audit_log UNION ALL projection(HQ_MANAGER actor + OPERATOR_IMPERSONATING 의 원본 운영자) + outer GROUP BY(accountId, role) → email 은 최신 audit 행 스냅샷. WHERE hq_id = :hqId 타 본사 격리. 기존 V27 인덱스 idx_hq_audit_actor_account_id 재사용(추가 인덱스·마이그레이션 0). FE /admin/audit 행위자 필터를 UUID 자유 입력 → select 전환(useListHqAuditActors mount 1회·옵션 라벨 {email} · {roleLabel} · {occurrenceCount}건·빈 옵션 “전체 행위자”·옵션 0건이면 disabled + “감사 로그가 없습니다.” 보조 안내). 자유 입력 sanitize·UUID_REGEX 제거. 같은 SPEC 에서 apps/space 인라인 formatKstDateTime 사본을 공용 @/lib/format helper 로 마이그레이션(announcements list·playlist detail 인라인 사본 삭제 → 정책 일관 24시간제·#066 §Followups 마감). 비파괴 변경·새 env var 0. | |
SPEC #067 F2 후속 마감 — HqAuditAction 에 3종 확장(HQ_ANNOUNCEMENT_CREATED·UPDATED·DELETED, 총 4종). HqTtsAnnouncementService.create/update 끝, HqTtsAnnouncementQueryService.delete 에 audit hook 1줄(같은 트랜잭션, audit INSERT 실패 시 액션도 함께 롤백). detail 스냅샷: CREATE title·voice·tonePreset·durationSeconds(null=“null”) · UPDATE title·voice·tonePreset·resynthesized={true|false}(no-op시 early return → audit 노이즈 0) · DELETE title(soft-delete 전 사전 fetch). update 의 외부 부작용(storagePort.upload) 을 save·audit 이후 마지막에 재배치해 audit 실패 시 blob 미덮어쓰기(정상 흐름 일관성·극한 케이스는 outbox 후속). 마이그레이션 0건(VARCHAR(48) 그대로). FE /admin/audit 페이지의 ACTION_LABELS/ACTION_TONES 가 자동 컴파일 에러로 누락 노출 → 4종 라벨/톤 추가(“생성”/info · “수정”/info · “송출”/info · “삭제”/warn). 액션 select 도 Object.values(HqAuditItemAction) 로 자동 확장. 잔여 후속: store_audit_log | |
HQ_MANAGER actor 감사 백본 완료 (#061 D8 “audit 첫 슬라이스 생략 — HQ_MANAGER↔operator_audit_log FK 호환 불확실” 후속 마감). 신규 테이블 hq_audit_log(V27) + 3 enum(HqAuditAction.HQ_ANNOUNCEMENT_DISPATCHED · HqAuditActorRole.HQ_MANAGER/OPERATOR_IMPERSONATING · HqAuditTargetType.TTS_ANNOUNCEMENT) + listHqAuditDispatches(GET /api/v1/hq/audit/dispatches, query from?·to?·actorAccountId?·action?·targetId?·q?·page?·size? → 200 HqAuditListResponse{items,page,size,total}, 행=HqAuditItem{id·occurredAt·actorEmail·actorRole·impersonatedByEmail?·action·targetType·targetId?·targetLabel?·detail?}, 정렬 occurred_at DESC, id DESC 서버 고정). 기록 hook = HqAnnouncementDispatchService.dispatch 끝, 같은 트랜잭션 내 HqAuditService.record — actor 해석(principal.accountId HQ_MANAGER · principal.impersonatedBy 있으면 OPERATOR_IMPERSONATING 동시 기록) · detail 스냅샷(`target=ALL | |
점장(STORE_MANAGER) 액션 감사 백본 완료 + 대기 중이던 점장 audit tail 4건 일괄 마감(#112 ticket·#087 F4 프로필·#100 F2 비밀번호·#092 F2 예약 취소). 신규 테이블 store_audit_log(V36) — hq_audit_log(V27) 1:1 미러(테넌트 스코프 키만 hq_id → store_id·actor 컬럼·임퍼소네이션 CHECK 정합성·인덱스 전략 동일·append-only). 3 enum 신규: StoreAuditAction 5종(STORE_TICKET_CREATED·STORE_TICKET_COMMENT_ADDED·STORE_PROFILE_UPDATED·STORE_PASSWORD_CHANGED·STORE_DISPATCH_CANCELED, 이후 #121 STORE_TICKET_CLOSED 추가로 6종) · StoreAuditActorRole(STORE_MANAGER/OPERATOR_IMPERSONATING) · StoreAuditTargetType(SUPPORT_TICKET·STORE_MANAGER·DISPATCH). StoreAuditService(HqAuditService 미러 — actor 해석·record/recordAndFlush·별도 @Transactional 없이 호출 service 트랜잭션 합치). hook 4(액션 성공 직후 같은 트랜잭션·audit INSERT 실패 시 본 작업 롤백): StoreTicketService.createStoreTicket/addStoreTicketComment(detail title·category·bodyLength/bodyLength) · StoreMeService.updateMe(detail changedFields=name·before->after·멱등 no-op 시 생략) · 공용 AuthService.changePassword role=STORE_MANAGER 분기(detail 없음·보안·임퍼소네이션 차단되어 actor 항상 본인) · StoreBroadcastService cancel(detail 없음·원자 UPDATE affected=1 확정 시에만). actor 임퍼소네이션 분기(운영자 점장 모드 위장 액션도 원본 운영자 스냅샷 동시 기록·lookup 실패 시 IllegalStateException 롤백). v1 = 기록만 — 조회 view(운영자/본사) 는 후속(D5, HqAudit 도 기록 먼저·view 나중). public endpoint 0 · API 무변경. 마이그레이션 V36 단일 CREATE TABLE(무중단 add·ddl-auto=validate 정합). 본사 비번 변경(#099) audit·동작 회귀 0. docs: data-schema V36·StoreAuditLog·enums 3종·features/audit/store(신규)·store/customer-support·profile·broadcast·홈 index. 잔여 후속: apps/admin /audit/store 매장 audit 탭 — hq audit #081 미러·listStoreAudit/exportStoreAudit) · 본사 매장 audit view(별 SPEC) · 점장 액션 enum 확장 · 실시간 audit·보존 정책(본사·운영사 공통). | |
SPEC #115 도착 — #114 D5 reader 마감. 운영자 백오피스(apps/admin)에 hq audit 탭(#081)을 1:1 미러한 매장 audit 탭(/audit/store) 추가 — 모든 매장의 store_audit_log 통합 조회(listStoreAudit·WHERE store_id 가드 없음 OPERATOR-only)·필터(기간·액션·storeId·검색 q)·페이지네이션·CSV 내보내기(exportStoreAudit·X-Export-Truncated 잘림 안내·매장감사_*.csv). StoreAuditQueryService + AuditController.listStoreAudit/exportStoreAudit(BE) · audit-store-client.tsx·audit-tabs.tsx “매장 audit” 탭(FE). read-only·권한 변경 0(append-only #114). 잔여: 본사 매장 audit view(자기 산하 매장 WHERE store_id IN (...) 스코프 — 별 SPEC). | |
FE-only 슬라이스 완료 — DispatchAnnouncementDialog 에 송출 대상 모드 라디오 토글 추가(전체 매장 ALL / 선택 매장 STORES, 기본 ALL). STORES 모드 진입 시 useListHqStores({ size: 100 })(SPEC #051 재사용) 로 산하 매장 1회 로드 + 클라이언트 측 case-insensitive 매장명 검색 + 체크박스 다중선택(Set<string> state) + 카운터 N개 선택 / 검색 결과 M개 + 전체 선택/전체 해제(현 검색 결과 범위) 보조 액션 + SUSPENDED/폐점 배지(있을 때만). 빈 선택 가드: STORES 모드 + 0개 선택이면 송출 버튼 disabled + 매장 1개 이상을 선택해 주세요. 안내. mutation 페이로드: ALL → { target: "ALL" }, STORES → { target: "STORES", storeIds: [...] }. 결과 배너 첫 줄에 대상: 전체 매장 또는 대상: 선택 매장 N개 보조 줄(N = 보낸 storeIds 길이) — onDispatched 콜백에 dispatchMode·selectedStoreCount 추가. 에러 매핑: DISPATCH_INVALID_TARGET (400) → “송출 대상이 올바르지 않습니다.”, DISPATCH_STORE_NOT_FOUND (404) → “선택한 매장 중 본사 산하가 아닌 매장이 있습니다.” 다이얼로그 폭 520→720px(매장 리스트 가독). BE 백본은 SPEC #061 의 DispatchTarget.STORES·storeIds 검증·audit detail 그대로 재사용(BE 변경 0건). 시안 발명 없이 기존 admin 영역 atom 만(라디오·체크박스·Input·Banner·Button·Dialog). 100개 초과 본사 서버 검색 페이지네이션은 F2 후속. | |
FE-only 슬라이스 완료 — DispatchAnnouncementDialog 본문 최상단에 미리듣기 섹션 + 재생 1회 의무 게이트 추가(SPEC #064 MiniPlayer 패턴 inline 미러 — 새 컴포넌트 추출 없음). 다이얼로그 open 시점에 useGetHqTtsAnnouncement(announcementId, { query: { enabled: open } }) 로 detail 을 lazy fetch(같은 쿼리키를 AnnouncementRowPlayer 가 이미 채워뒀으면 react-query 캐시 hit). detail.audioUrl 에 ?v={target.updatedAt} 캐시버스터(목록 prop 우선 — announcement-row-player 정책 일관). UI: ▶/⏸ 토글 · 진행바(role=progressbar·aria-valuenow) · m:ss / m:ss 시간 · 안내방송 제목. <audio> onPlay 이벤트가 1회라도 발생하면 previewed=true 게이트 통과(끝까지 듣기 강제는 UX 마찰 큼·F1 후속). 통과 전엔 송출 버튼 disabled + 보조 텍스트 “미리듣기를 1회 이상 재생해 주세요”(우선순위: 미리듣기 미통과 > STORES 빈 선택 > mutation pending). 통과 후 우측 상단 “미리듣기 완료” 표식. 다이얼로그 닫힘 → audio paused + currentTime=0 + previewed=false 리셋(useEffect([open]) 단일 진실원 — 재오픈 시 게이트 다시 강제). phase: loading 스피너 / error Banner danger + 다시 시도 / edge audioUrl 빈 문자열 → Banner danger “오디오를 찾을 수 없습니다. 다시 시도해 주세요” + audio mount X(노이즈 요청 회피). 부모(HqAnnouncementListClient)는 dispatchTarget 에 list 행 updatedAt 을 함께 넘겨 캐시버스터 일관 유지. BE 변경 0건. 시안 hq-broadcast-new Step 2 정렬. 잔여 후속: 끝까지 재생 정책(F1) · 스케줄(F2) · REGION(F3) · 우선순위/더킹(F4). | |
SPEC #068 F4 마감 — 운영사가 모든 본사의 hq_audit_log 를 한 화면에서 통합 조회. BE: 신규 listOperatorHqAudit(GET /api/v1/admin/audit/hq, OPERATOR-only) → 200 OperatorHqAuditListResponse{items, page, size, total} · 행 OperatorHqAuditItem{id·hqId·**hqName**(어느 본사인지 식별, hq JOIN)·occurredAt·actorEmail·actorRole(HQ_MANAGER/OPERATOR_IMPERSONATING)·impersonatedByEmail?·action(5종 — DISPATCHED·CREATED·UPDATED·DELETED·DISPATCH_CANCELED)·targetType(TTS_ANNOUNCEMENT)·targetId?·targetLabel?·detail?}. query from?·to?·hqId?(옵션·null=전체 본사)·actorAccountId?·action?·targetType?·q?(≤100)·page?·size?. 정렬 occurred_at DESC, id ASC 서버 고정. 본사 view(#067)와의 차이: WHERE hq_id 가드 없음(OPERATOR-only — 전체 본사 합집합 정책 결정). HQ_MANAGER 호출 → 403. FE: apps/admin /audit/hq 신규 페이지(apps/space 본사 audit hq-audit-client 패턴 미러 + hqName 컬럼 추가 · 행 컬럼 = 발생시각(KST)·본사명·액션(StatusPill 5종 — 본사 매핑과 동일)·대상(타입 배지 + 라벨)·행위자(이메일·역할 배지·임퍼소네이션 보조 줄 ↩ impersonatedByEmail)·상세). 필터 = 검색 q + 본사 UUID 자유 입력(F2 select 후속) + 액션 select + 대상 유형 select + 기간 from/to(KST +09:00 경계 정규화). draft↔applied 분리. <AuditTabs> 3번째 탭 “본사 audit” 추가(/audit/impersonation · /audit/actions · /audit/hq). 사이드바 “감사 로그” 단일 항목 그대로 — 탭 공유. 새 env var 0 · 시안 발명 X(apps/space row idiom + apps/admin actions 헤더 idiom 미러). 잔여 후속: apps/admin #048 패턴 미러) | |
SPEC #081 F1 마감 · #074 패턴 완전 미러 · CSV export 4종 완결(#048 운영자 액션·#070 본사 송출·#074 임퍼소네이션·#088 운영사 통합 본사 audit) — 신규 exportOperatorHqAudit(GET /api/v1/admin/audit/hq/export, OPERATOR-only) → 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename*=UTF-8''본사통합감사_{yyyyMMdd_HHmmss}.csv(RFC5987 percent-encoded · 한글 파일명) + X-Export-Truncated: true|false 헤더(상한 신호). query = listOperatorHqAudit 와 동일(from·to·hqId·action·targetType·q) 단 page/size 제외. 컬럼 8종(한국어 헤더): 발생시각KST·본사명·액션(송출/생성/수정/삭제/송출 취소)·대상 유형(안내방송)·대상 라벨·행위자 이메일·행위자 역할(본사 매니저/운영자 위장)·상세. 상한 EXPORT_MAX=50000 행(베타 규모 충분) 초과 시 50,000+1 peek-ahead 감지 후 50,000 row 만 emit + 헤더 true. 정렬 occurred_at DESC, id ASC 서버 고정. CSV injection 방어(공용 AuditCsvWriter SPEC #048/#070/#074 와 동일 모듈 — =·+·-·@·탭·CR 시작 셀에 ' prefix; new method writeOperatorHqAuditCsv). 403 ErrorResponse 는 mediaType="application/json" 명시(produces=text/csv 상속 회피 — #048 회귀 가드). HQ_MANAGER 호출 → 403(list 와 동일 정책). 마이그레이션 0건. FE: generated orval hook 우회(apiFetch 가 모든 응답을 res.text()→JSON 파싱이라 CSV 부적합) → 공용 helper apps/admin/src/lib/csv-export.ts downloadCsvFromBackend(path, query, fallbackFilename) 재사용(#048/#070/#074 와 동일 모듈 — 브라우저에서 BFF /api/backend/v1/admin/audit/hq/export?... 직접 fetch → res.blob() → URL.createObjectURL → 앵커 download 속성 클릭 → revoke. 401 감지 시 /login?next= 풀 리다이렉트). apps/admin /audit/hq PageHeader.primary 슬롯 [CSV 내보내기] 버튼(lucide:Download·loading state “다운로드 중…” + aria-busy + disabled). 잘림 시 Banner variant="warn" “결과가 50,000행으로 잘렸습니다. 필터를 좁힌 뒤 다시 시도해 주세요.” · 실패 시 Banner variant="danger" “CSV 내보내기에 실패했습니다. 잠시 후 다시 시도해 주세요.” · 둘 다 [닫기]. AuditTabs 와 본문 사이 배치. 보낼 필터는 draft 가 아닌 URL applied 값 — date-only 는 KST(+09:00) 경계 정규화(from=00:00:00.000·to=23:59:59.999, #074 미러). generated ExportOperatorHqAuditParams 의 actorAccountId 는 list UI 가 노출하지 않으므로(F2 행위자 select 후속) 항상 undefined(keyof 전수 강제 가드용). BFF 는 catch-all [...path] 이라 신규 라우트 X. 운영사 페르소나 추가(percent 98→99%). 비파괴 변경·새 env var 0. 후속: PDF · 자동 스케줄. | |
SPEC #048 패턴 완전 미러 · CSV export 3종 완결(#048 운영자 액션·#070 본사 송출·#074 임퍼소네이션) — 신규 exportImpersonationAudit(GET /api/v1/admin/audit/impersonation/export, OPERATOR-only) → 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename*=UTF-8''임퍼소네이션감사_{yyyyMMdd_HHmmss}.csv(RFC5987 percent-encoded · 한글 파일명) + `X-Export-Truncated: true | |
| 매장 그룹 / 캠페인 / 안내방송 송출 고도화 | PRD 11-prd-store-hq-mode.md (스케줄·REGION·CM송·결제·CS·설정 잔여 페이지). 송출 첫 슬라이스(전체 매장 ALL)·매장 개별선택(STORES #072)·미리듣기 게이트(#073)·예약/스케줄(#078) 은 도착, REGION·더킹·우선순위·반복 예약·일정 캘린더 view 는 후속 |
BE+FE 슬라이스 완료 — BE: DispatchStatus.SCHEDULED 추가(VARCHAR enum) + V29 announcement_dispatch.scheduled_at timestamp NULL + partial index idx_announcement_dispatch_scheduled(status, scheduled_at) WHERE status='SCHEDULED'. DispatchRequest.scheduledAt: Instant? 추가(null=즉시·non-null=예약). 검증 <= now → 400 DISPATCH_SCHEDULED_AT_PAST · > now+1y → 400 DISPATCH_SCHEDULED_AT_TOO_FAR. 신규 HqDispatchScheduler.kt @Scheduled(cron='0 * * * * *') 1분 cron + @ConditionalOnProperty('scheduler.dispatch.enabled', matchIfMissing=true) — 원자 UPDATE(SET status='PENDING' WHERE status='SCHEDULED' AND scheduled_at <= :now LIMIT 1000) → 점장 player polling 자동 픽업(코드 변경 0). 멱등(중복 fire/race 시 0 row). 시스템 액션이라 audit 미생성(등록 시점 audit detail 에 scheduled_at 포함). cancel endpoint(#077) 가 SCHEDULED 도 허용(WHERE id AND hq_id AND (status='PENDING' OR status='SCHEDULED')). DispatchHistoryItem.scheduledAt + DispatchHistoryAggregate.scheduled 추가. FE: DispatchAnnouncementDialog 에 송출 시점 모드 라디오 토글(즉시/예약·기본 IMMEDIATE) + SCHEDULED 시 date input(min=오늘 KST) + time input(현재+1h KST 자동 채움) + KST→UTC ISO 정규화 + 클라 검증(빈/과거 → disabled + 안내) + 라벨 분기(“예약 등록하기” / “예약 등록 중…”) + BE 에러 매핑(2종) + 다이얼로그 닫힘 시 모드/입력 리셋. HqAnnouncementListClient 결과 배너가 SCHEDULED 면 info 톤 "{KST 시각}에 안내방송 예약 등록 완료(N개 매장)" 로 분기. DispatchHistoryDialog 가 StatusPill 4종(SCHEDULED=info “예약” 추가)·헤더 chip 5종(예약 N 추가, aggregate.scheduled 사용)·SCHEDULED row 송출 시각 셀에 scheduledAt 노출(createdAt 은 title tooltip 보조)·[취소] 버튼 SCHEDULED + PENDING 양쪽 활성·[재송출] SCHEDULED 도 활성. 시안 hq-broadcast-new Step 3 미러. 새 env var 0(기본 enable). BE+FE 비파괴 슬라이스. 잔여 후속: F1 본사 일정 캘린더 view(hq-schedule.jsx) · F2 반복 예약 · F3 우선순위/더킹. | |
Critical state ① — 송출 전 1회 재생 강제(시안 Step2 게이트) — SPEC #073 도착(FE-only · MiniPlayer 패턴 미러 · onPlay 1회 게이트). 끝까지 재생 정책은 F1 후속(현재 onPlay 1회). | |
/admin/settings) | SPEC #085 완료 — BE 신규 endpoint updateHqMe(PATCH /api/v1/hq/me, HQ_MANAGER, UpdateHqMeRequest { name?: string | null } → 200 HqMeResponse). 본인 매니저 본인 이름만 부분 업데이트(null/생략=미변경·non-null 1HqMeService.updateMe(verifyHqScope → OperatorAccount 본인 row JPA dirty checking) · audit 본 슬라이스 0(F4 후속 HQ_MANAGER_PROFILE_UPDATED). FE: apps/space /admin/settings 신규 페이지(server shell + client) — 2 섹션 구성: 본인 정보 read-only(본사명·유형·요금제·상태·사업자번호·매장 수·생성 시각 7행 DescCard) + 본인 매니저 이름 편집 form(input + [저장] · 1@minLength 1 @maxLength 50 → 빈/51자+ disabled). 에러 매핑 코드 우선 + status 폴백(401/403/5xx/BACKEND_UNREACHABLE/그 외 4xx). HQSidebar “설정” 항목 enable(#085) — 음악 2계층 + 송출 백본 + audit + 매장 상세에 이은 본사 모드 본인 surface 완결. 시안 부재 — atom-grounded 임시 레이아웃(매장 상세 #084 DescCard idiom + 안내방송 #061 form idiom 미러). 잔여 후속(F): updateHq(PATCH /api/v1/admin/hq/{id}, body UpdateHqRequest{name} 비-blank·≤255 → 200 HqDetailResponse read-back)·운영자 audit HQ_UPDATED·apps/admin /hq/[id] 개요 본사명 인라인 편집(#105 매장 편집 패턴 미러·성공 시 router.refresh). 본사 자기수정은 정책상 불가(변경 없음). · HQ_MANAGER_PROFILE_UPDATED 액션 6종 확장). 새 env var 0 · BE+FE 비파괴. |
FE-only 슬라이스 — /admin/settings 비밀번호 변경 섹션 추가. BE 변경 0(기존 POST /api/v1/auth/change-password + BFF /api/auth/change-password 재사용). HqSettingsClient 최하단 새 section + 카드(섹션 헤더 “비밀번호 변경”, 보조 “8<input type="password"> autocomplete 적절). 클라이언트 검증: 모두 입력 · 8 | |
/admin/commercials) | SPEC #093 완료 — BE: V31 commercial_song 테이블 신설(id·hq_id·title VARCHAR(200)·audio_url TEXT·duration_seconds·is_active DEFAULT TRUE·created_at·updated_at·deleted_at + partial index idx_commercial_song_hq_active(hq_id, is_active) WHERE deleted_at IS NULL). 신규 endpoint 5종 /api/v1/hq/commercials/* (HQ_MANAGER): listHqCommercials(검색 q·isActive 필터·페이지네이션)·createHqCommercial·getHqCommercialDetail·updateHqCommercial(부분 갱신 — title?/isActive? null=미변경·audioUrl 변경은 후속)·deleteHqCommercial(204 soft-delete). 격리: 모든 endpoint WHERE hq_id = :hqId AND deleted_at IS NULL 강제(BE D3) — 타 본사 row·삭제 row 는 404 COMMERCIAL_SONG_NOT_FOUND 존재 은닉. 검증: title 1@URL ≤2048·durationSeconds 1created_at DESC, id ASC 결정적(BE D5). FE: apps/space 신규 3페이지 — 목록(/admin/commercials, 검색·활성 필터·페이지네이션 5컬럼: 제목·재생 시간(mm:ss)·활성 배지·등록 시각 KST·수정 시각 KST·제목 셀 클릭 → 상세) + 등록(/admin/commercials/new, title 1/admin/commercials/{id} push) + 상세(/admin/commercials/[id], 4 섹션: 정보 DescCard(제목·재생 시간·활성·audioUrl <a target=_blank>·시각) + 미리듣기 <audio controls src={audioUrl}> (#062 패턴) + 편집 form(제목 input + Switch 활성 토글·변경 없으면 [저장] disabled · PATCH title+isActive 동시 전송) + 삭제 빨간 버튼 + 2-step 인라인 confirm strip(404=이미 삭제됨 흡수 → 목록 push)). HQSidebar “CM송” 항목 enable(icon Volume2, placeholder /admin/cm → /admin/commercials 정렬, #080 라이브러리 정렬과 동형). 음원 업로드 UI 는 후속 — 본 슬라이스는 audioUrl 을 사용자가 직접 입력(SPEC §D9 가드). 시안 부재 — atom-grounded 임시 레이아웃(본사 라이브러리 #080 목록 idiom + 안내방송 #061 form/audio idiom + 매장 상세 #084 DescCard idiom 미러, 새 컴포넌트 추출 X). 검증 backend OpenAPI 제약 미러. 에러 매핑 401/403/5xx/BACKEND_UNREACHABLE/그 외 4xx 분리(목록·등록·편집·삭제 각각). 새 env var 0 · BE+FE 비파괴. 잔여 후속(F): getStoreNextCommercial GET /api/v1/store/commercial-song/next STORE_MANAGER-only, 200 StoreCommercialNextResponse{id,audioUrl,durationSeconds} 랜덤 1건 또는 204 CM 미등록, WHERE hq_id=:hqId AND is_active=TRUE AND deleted_at IS NULL ORDER BY RANDOM() LIMIT 1. FE: store-player-client 음악 곡 ended 카운터 + 모듈 상수 SONGS_BETWEEN_COMMERCIALS=5 도달 시 imperative fetch → 200 CM 삽입 재생 + CM ended → 카운터 리셋 + 다음 음악 · 204/실패 silent · 안내방송 재생은 카운팅 X · 인터럽트 우선순위 긴급 > 일반 안내방송 > CM). 잔여 후속(F): hq.commercial_cycle_songs INT NOT NULL DEFAULT 5 컬럼 ADD · HqMeResponse.commercialCycleSongs·StoreMeResponse.hqCommercialCycleSongs 노출 · UpdateHqMeRequest.commercialCycleSongs? 1~100 추가. FE: /admin/settings “CM송 사이클 빈도” 섹션 신규(number input + [저장] + Banner) · store-player-client 모듈 상수 SONGS_BETWEEN_COMMERCIALS=5 → useGetStoreMe().hqCommercialCycleSongs 동적값 + fallback 5(me 도착 전·레거시 0)). · store.last_commercial_song_id UUID NULL FK ON DELETE SET NULL 컬럼 + StoreCommercialService.getNext 가 id > last 다음 후보(없으면 wrap-around 로 첫 CM)를 native 로 가져온 뒤 같은 트랜잭션 dirty-checking 으로 last 갱신. 매장 단위 라운드로빈으로 같은 CM 연속·1건 미노출 분포 문제 해소(랜덤 → 결정적). FK ON DELETE SET NULL 로 referenced CM hard-delete 시 자동 clear → 다음 호출 wrap-around 회복. 통합 테스트 5건(기존 3건 + 라운드로빈 A·B·C·A 순환 + 중간 CM soft-delete skip). · F4 CM송 라이브러리 묶음(음원/플레이리스트 2계층의 별도 카테고리화) · F5 audit 누적(HQ_COMMERCIAL_* 액션, HqAuditAction 확장). 음원 파일 업로드 UI 는 별도 후속(BE blob 업로드 endpoint + FE 업로드 form). |
/admin/support) | SPEC #086 완료 — BE 신규 endpoint 4종 /api/v1/hq/tickets/*(HQ_MANAGER): listHqTickets·createHqTicket·getHqTicketDetail·addHqTicketComment. 운영자 ticket 백본(#027) 의 service 를 재사용하되 본사 view 는 별도 DTO 5종(HqTicketListItem·HqTicketListResponse·HqTicketDetailResponse·HqTicketCommentItem·CreateHqTicketRequest·AddHqTicketCommentRequest)으로 분리 — 운영자 assignee·INTERNAL 메모 등 과노출 회피(BE D5). hqId·submitterAccountId 토큰 주체 도출(타 본사 격리·WHERE ticket.hq_id = :hqId 강제·타 본사·미존재 모두 404 TICKET_NOT_FOUND 은닉 D3). 초기 status=OPEN·priority default NORMAL(D1). 정렬 created_at DESC, id ASC 결정적(D7). FE: apps/space 신규 3페이지 — 목록(/admin/support, 검색·status·priority 필터·페이지네이션, 5컬럼: 제목·상태 배지·우선순위 배지·댓글 수·작성 시각 KST·행 클릭 → 상세) + 작성(/admin/support/new, title ≤200·body ≤5000·priority LOW/NORMAL/HIGH 3종 노출·성공 시 /admin/support/{id} replace 네비) + 상세(/admin/support/[id], 4 섹션: 헤더+상태/우선순위 배지·메타(작성자·시각)·본문 pre-wrap·댓글 스레드 시간순+authorRole 배지(HQ_MANAGER=“내 매장”/OPERATOR=“운영사”)+추가 form·404 = “문의를 찾을 수 없습니다.”). HQSidebar “고객지원” 항목 enable(icon LifeBuoy). 시안 부재 — atom-grounded 임시 레이아웃(본사 /admin/audit idiom + 운영자 /tickets idiom 미러). 검증 backend OpenAPI 제약 미러(빈/길이 초과 disabled + 카운터). 에러 매핑 401/403/5xx/BACKEND_UNREACHABLE/그 외 4xx 분리. 새 env var 0 · BE+FE 비파괴. 잔여 후속(F): |
BE: HqAuditAction 2종 추가(HQ_TICKET_CREATED·HQ_TICKET_COMMENT_ADDED, 총 7종) + HqAuditTargetType.SUPPORT_TICKET 추가(총 2종). HqTicketService 에 HqAuditService 주입 + createHqTicket·addHqTicketComment 끝에 audit hook(같은 트랜잭션, INSERT 실패 시 ticket 도 함께 롤백·#068 패턴). detail 스냅샷: 생성 = title=...·priority=NAME·bodyLength=N · 댓글 = bodyLength=N(본문은 audit 에 저장 X). 통합 테스트 2 케이스 추가(14 → 16). FE: apps/space /admin/audit 와 apps/admin /audit/hq 양쪽 ACTION_LABELS/ACTION_TONES 에 2종 추가(“티켓 생성”·“티켓 댓글” info 톤). 운영자 /audit/hq 의 TARGET_TYPE_LABELS 에 SUPPORT_TICKET=“CS 티켓” 추가. 본사 매니저의 ticket 활동이 본사·운영사 두 audit 화면에 일관 노출. 새 env var 0 · 새 마이그레이션 0. HQ_TICKET_STATUS_CHANGED·HQ_TICKET_PRIORITY_CHANGED 2종 추가, 총 11종) · F2 운영자 응답 audit(operator audit 영역). | |
BE: 신규 endpoint 2종 PATCH /api/v1/hq/tickets/{id}/status(changeHqTicketStatus)·PATCH .../priority(changeHqTicketPriority, HQ_MANAGER). DTO 4종(HqTicketStatusChangeRequest/Response·HqTicketPriorityChangeRequest/Response). F1 제한적 상태: 본사 전이표 = 운영자 전이표 부분집합 — RESOLVED→CLOSED·RESOLVED→IN_PROGRESS·CLOSED→IN_PROGRESS 만(OPEN→*·IN_PROGRESS→RESOLVED 운영자 전담, 위반 409 TICKET_INVALID_STATUS_TRANSITION). F2 URGENT 제외: 본사는 LOW/NORMAL/HIGH 만(URGENT 요청 400 HQ_TICKET_PRIORITY_FORBIDDEN). hqId 격리 원자적 UPDATE(WHERE id AND hq_id [AND status=from]) — 타 본사 id·미존재 404 TICKET_NOT_FOUND 은닉(D2). audit 2종 추가(HQ_TICKET_STATUS_CHANGED·HQ_TICKET_PRIORITY_CHANGED, detail before→after, 우선순위 동일값 멱등 시 생략, 총 11종). FE: apps/space /admin/support/[id] 에 처리 섹션(hq-ticket-detail-actions) 추가 — 상태 변경(현재 status 의 본사 허용 전이 버튼만: RESOLVED→[확인 종료]/[재개], CLOSED→[재개], OPEN/IN_PROGRESS 는 “운영자가 처리 중입니다” 안내) + 우선순위 세그먼트(LOW/NORMAL/HIGH, URGENT 옵션 없음·현재값 재선택 no-op). 성공 시 getGetHqTicketDetailQueryKey(id) invalidate + success 배너. 에러 매핑 409 비허용 전이·400 URGENT 방어망·404 race·401/403/5xx inline Banner. 운영자 tickets/[id] ActionCard idiom 미러 + 본사 톤. 11 신규 테스트(RESOLVED 2버튼·CLOSED 1버튼·OPEN/IN_PROGRESS 안내·우선순위 3옵션·URGENT 미노출·no-op·409/400 매핑·success). docs 7종 갱신(customer-support·endpoints·dtos·surface-matrix·audit·pending-features·홈 index). 새 env var 0. | |
| 매장 클라이언트 앱 | space.linkmusic.io — 별도 sub-app 검토 |
Surface 12 (점장 모드)
| 항목 | 비고 |
|---|---|
| STORE_MANAGER 발급(#021)·관리(#029) 운영사측 완료 + 인가 매처 | |
SPEC #049 완료 — getStoreMe(GET /api/v1/store/me, StoreMeResponse — 소속 본사 id·name 포함). /api/v1/store/** → hasRole("STORE_MANAGER") 1차 경계 + PrincipalScopeGuard 재검증. 점장 클라이언트 본인 매장 표시 토대 | |
/store/* 라우트 도입 | apps/space 통합 앱(별도 sub-app 아님) /store — STORE_MANAGER layout 가드(#050)·player 홈(#064) |
SPEC #064 완료 — apps/space /store Classic 시안 player(Store Player Home). 재생 큐 소비(useGetStorePlaybackQueue #056)·HTML <audio> 재생/일시정지·이전/다음·진행바 seek·볼륨·곡 끝 자동 다음·큐 소진 refetch(서버 재셔플)·풀폭 재생 큐 목록(현재곡 하이라이트·행 클릭 점프)·source=DEFAULT 기본 재생목록 안내·빈 상태(NO_ACTIVE_PLAYLIST/EMPTY_PLAYLIST)·매장명(getStoreMe)·[재시작] in-app 복구. 보조 액션(즉시방송·방송템플릿·예약·PL 선택)은 후속 라우트 placeholder stub 으로 링크만 연결. cover 는 brand placeholder(커버 enrich 후속) | |
송출 슬라이스 완료 — 점장 player 가 listStorePendingAnnouncements 를 20초 폴링해 곡 끝에 본사 송출 안내방송을 1회 삽입 재생하고 ackStoreAnnouncement(멱등) 후 음악 복귀. audio 에러 시에도 ack(무한 멈춤 방지). “본사 안내방송 재생 중” 배지. 즉시 중단·더킹·우선순위는 후속 | |
즉시방송 슬라이스 완료 — apps/space /store/broadcast/now(Store Broadcast Now). 2-step 미리듣기 게이트: previewStoreBroadcast(POST /api/v1/store/broadcasts/preview, text 1~200자+voice 5종 → STORE_BROADCAST draft·audioUrl)→화면 내 <audio> 미리듣기→sendStoreBroadcast(POST .../{announcementId}/send → 본인 매장 PENDING 송출). 송출 버튼은 미리듣기 성공 후 활성·텍스트/목소리 변경 시 재비활성(재미리듣기 무효화)·“송출 후 취소 불가” 경고. 송출되면 기존 player 폴링이 재생(player 무변경). tts_announcement.source='STORE_BROADCAST'(V24)로 본사 안내방송과 격리 | |
즉시방송 녹음 슬라이스 완료 — apps/space /store/broadcast/now 탭 2종(TTS/녹음)으로 확장. 녹음 탭(recording-tab.tsx): 마이크 녹음(getUserMedia→MediaRecorder webm/opus 협상·isTypeSupported·30초 자동 정지)→<audio> 미리듣기→[다시 녹음]/[업로드]→recordStoreBroadcast(POST /api/v1/store/broadcasts/recording, multipart file(Blob)+durationSeconds query → BroadcastRecordingResponse)→TTS 와 공유 미리듣기 게이트(previewedAnnouncementId) 활성→공유 sendStoreBroadcast 송출. 마이크 권한 거부 UX(NotAllowedError/NotFoundError/NotReadableError 안내+재시도)·업로드 에러(RECORDING_UNSUPPORTED_FORMAT·RECORDING_INVALID_FIELD 400)·탭 전환 시 게이트 무효화·언마운트 정리(stop·track stop·objectURL revoke·타이머 clear)·aria-live. 계약 제약: MIME audio/webm·audio/mp4·audio/mpeg·≤10MB·1uploadMusic)와 동일 BFF catch-all 통과. 디자인: ✅ 시안 정합 완료 — design_8 store-broadcast-recording.jsx(§8E-②) 흡수. 상태별 카드(대기 72px·녹음 중 96px danger·진행바·녹음 완료 60px·올리는 중 스피너·마이크 권한 거부 3종)·80px 큰 버튼·40aria-live. 인터랙션 §8E-① 확정(시작/정지 = 탭 토글). 시각만 교체·동작 보존. 후속: Safari 트랜스코딩(webm 미지원 폴백)·긴급(isEmergency·audit 누적)·송출 rate limit·송출 이력/audit | |
자주쓰는방송 슬라이스 완료 — apps/space /store/broadcast/now 3번째 탭(templates-tab.tsx)으로 확장. 템플릿 CRUD: useListBroadcastTemplates(GET /api/v1/store/broadcast-templates → BroadcastTemplateListResponse{items,total}, updated_at DESC·매장당 ≤20)·useCreateBroadcastTemplate(POST, 201 / 상한 409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED)·useUpdateBroadcastTemplate(PUT /{id}, 200 / 소유 아님 404 BROADCAST_TEMPLATE_NOT_FOUND)·useDeleteBroadcastTemplate(DELETE /{id}, 204 소프트삭제). 목록(시안 행 레이아웃 미러: Star·name·text 요약·상대시각)·빈상태·인라인 저장/편집 폼(name 1onLoad(text,voice)→TTS 탭 전환+입력 채움+미리듣기 무효화(자동 미리듣기 안 함·점장이 직접 [미리듣기]·게이트 유지). broadcast_template 테이블(V25). CRUD 4종 BFF catch-all 통과. 디자인: ✅ 시안 정합 완료 — design_8 store-broadcast-templates.jsx(§8E-③) 흡수. 행 레이아웃(Star 박스·name·text 요약·voice pill·상대시각·편집/삭제 아이콘 버튼)·인라인 저장/편집 폼(카운터 병기)·인라인 2-step 삭제(행 자리 danger 확인 카드)·빈상태(점선 카드 + 큰 CTA)·상한 warn 배너. 인터랙션 §8E-① 확정(행 탭 = 로드·인라인 폼·인라인 2-step). 시각만 교체·동작 보존. 후속: 녹음 템플릿(현재 텍스트만)·즐겨찾기 정렬 | |
점장 긴급방송 슬라이스 완료 — SPEC #082. BE: V30 tts_announcement.is_emergency BOOLEAN NOT NULL DEFAULT FALSE (인덱스 없음 — 긴급 row 드묾). 3 request DTO(BroadcastPreviewRequest·BroadcastSendRequest·BroadcastRecordingRequest query) + 3 response DTO + PendingAnnouncementItem 에 isEmergency 노출. BroadcastSendRequest 신설 — preview 단계 값을 send 단계에서 마지막 확정(null/생략 시 preview 값 유지·true=긴급). FE: apps/space /store/broadcast/now TTS/녹음 탭에 시안 broadcast-now.jsx §EmergencyToggle 인라인 미러(새 컴포넌트 추출 X) — 체크박스 + 빨간 톤(var(--danger) border · color-mix(in oklch, var(--danger) 10%, transparent) background) + 경고 텍스트 “긴급 옵션은 미아·화재·정전·분실물·응급 안전 안내에만 사용하세요. 마케팅·이벤트 안내에 사용하면 감사 로그에 누적되어 본사에 보고됩니다.” 송출 버튼 라벨·톤 분기("긴급 송출하기" + variant="danger"). mutation 페이로드는 true 만 명시 전송(false 는 생략 — BE default false): preview/send JSON body, 녹음 query param. 토글 변경 시 미리듣기 게이트 무효화. 송출 후 emergency=false 리셋. 자주 쓰는 방송(템플릿) 탭은 토글 없음 — 템플릿 load 시 emergency=false 고정(긴급은 즉시 결정·템플릿화 X). apps/space /store/store-player-client.tsx. polling 응답의 isEmergency=true row 를 곡 끝 대기 없이 즉시 인터럽트: 현재 음악 currentTime 캡처(musicResumeAtRef) → audio pause → src 를 긴급 url 로 교체 + play → 긴급 audio ended/error → ack(404 흡수) → 음악 src 복원 + onLoadedMetadata 시점에 currentTime = resumeAt 적용. 다중 긴급 row 는 createdAt ASC 순차 처리(별도 큐 자료구조 X — pendingItems 필터). audio error 시 failedAnnouncementsRef 마킹(즉시 재진입 차단) + Banner danger “긴급 안내 재생 실패”. 종료 마킹 finishedAnnouncementsRef(ack mutate 진입 시 추가·settle 시 delete) 로 invalidate-폴링 사이 윈도우의 재트리거 차단(disp-stall 정합). UI: 본문 상단 Banner danger ”🚨 긴급 안내 재생 중” + hero 안내 배지 danger 톤 분기. isEmergency=false 는 기존 #061 곡 끝 삽입 동작 회귀 0. 시안 부재 atom-grounded — 새 컴포넌트 추출 X(inline). 후속: F2 본사 audit 누적(HqAuditAction.STORE_EMERGENCY_BROADCAST_DISPATCHED — 마케팅 오용 보고). | |
점장 예약방송 슬라이스 완료 — SPEC #083. BE: BroadcastSendRequest.scheduledAt: Instant? 추가(null=즉시·non-null=예약). 검증 <= now → 400 BROADCAST_SCHEDULED_AT_PAST · > now+1y → 400 BROADCAST_SCHEDULED_AT_TOO_FAR. StoreBroadcastService.send 가 scheduledAt == null → 기존 즉시 flow + status=PENDING, non-null → status=SCHEDULED, scheduled_at=scheduledAt. 본사 #078 HqDispatchScheduler 그대로 재사용(코드 변경 0) — 본사/점장 구분 없이 매분 cron 이 WHERE status='SCHEDULED' AND scheduled_at <= now row 를 PENDING 전이 → 점장 player polling 자동 픽업. 점장 row 는 본인 매장 1곳 fan-out(단일 row). FE: apps/space /store/broadcast/now 공유 송출 게이트에 송출 시점 모드 라디오 토글(즉시/예약·기본 IMMEDIATE) + SCHEDULED 시 <input type="date" min={오늘 KST}> + <input type="time"> + 자동 채움(현재+1h KST) + KST→UTC ISO 정규화 helper 3종(composeScheduledAtIso·defaultScheduledFields·todayKstDateString — 본사 #078 DispatchAnnouncementDialog 패턴 그대로 inline 미러, 공용 추출 X) + 클라 검증(빈/과거 → disabled + 안내) + 라벨 분기(“예약 등록하기” / “긴급 예약 등록하기” / “예약 등록 중…”) + BE 에러 매핑(2종) + 결과 배너 분기(KST 시각 + “예약 등록 완료”) + 송출 후 IMMEDIATE 리셋. 긴급 조합 자유(긴급 예약 = 정당한 use case). 자주 쓰는 방송(템플릿) 탭은 토글 없음(D13 — 송출 게이트 자체 숨김). TTS 탭과 녹음 탭이 공유 게이트의 schedule UI 를 그대로 사용(녹음 업로드 단계엔 scheduledAt 없음 — 송출 단계 body 에만 실린다). 후속: F1 본사 점유 슬롯 차단(20-policy §3-3 — 본사 예약과 시간 겹치면 차단, 겹침 정책 결정 후) · cancelStoreDispatch PATCH /api/v1/store/dispatches/{id}/cancel STORE_MANAGER-only, 원자 UPDATE WHERE id AND store_id AND status='SCHEDULED', 0행=404 DISPATCH_NOT_FOUND 은닉). 본사 #077 차이: 점장은 SCHEDULED 만 취소(본사가 즉시 보낸 PENDING 무효화 정책 위반 — §D5). apps/space /store/broadcast/scheduled 행 inline [취소] 버튼 + 헤더 아래 confirm strip(**{제목}** 예약을 취소하시겠습니까? [취소 확정] [닫기] — 본사 #077 strip 패턴 정확 미러) + 성공 시 list invalidate + row 제거 · 404 → “이미 처리됐거나 취소된 예약입니다.” · 5xx → “예약 취소에 실패했습니다.” audit 0(F2 후속). · listStoreScheduledBroadcasts GET /api/v1/store/scheduled-broadcasts STORE_MANAGER-only, scheduled_at ASC 상위 50건, StoreScheduledBroadcastItem{dispatchId·announcementTitle·audioUrl·scheduledAt·isEmergency·createdAt}). apps/space /store/broadcast/scheduled read-only 표(제목·예약 시각 KST·긴급 배지·등록 시각) + 즉시방송 페이지 헤더 [예약 목록] 진입점 + #092 inline [취소] 라이브. 잔여: store_audit_log STORE_DISPATCH_CANCELED). | |
**BE: findStoreScheduled 반환 List<> → Page<> + countQuery. service listScheduled 시그니처 (principal, page=0, size=20) + BroadcastScheduledPageView (size 1..50 clamp, page coerceAtLeast(0)). controller @RequestParam page? size? + envelope 응답 + OpenAPI desc 갱신. DTO StoreScheduledBroadcastListResponse 에 page·size·total: Long 3 필드(전 필드 @Schema(example)). 통합 테스트 4 케이스 추가(envelope default·size=50 + 2페이지·size 999 → 50 clamp·page -3 → 0 coerce). 9 → 13. FE: sync-api 로 generated 타입 자동 갱신. store-scheduled-list-client.tsx 0-base page state + useListStoreScheduledBroadcasts({ page, size: 20 }) + 하단 ListPagination(1-base display, total > size 일 때만 노출, page=0 default). 페이지 컨트롤은 total/PAGE_SIZE 페이지 합 표기. 새 env var 0 · 새 마이그레이션 0. 잔여: | |
FE-only 슬라이스 (BE/sync 0) — 예약 목록 DTO(StoreScheduledBroadcastItem)가 이미 audioUrl 포함 → 네이티브 <audio> 추가만. store-scheduled-list-client.tsx: 각 행 미리듣기 컬럼에 [미리듣기] 토글 버튼(Play 아이콘 + aria-expanded) → 클릭 시 그 행 아래 펼침 <tr colSpan=6> 에 네이티브 <audio controls preload="metadata" src={audioUrl}> 노출(CM송 #093·안내방송 #062 미리듣기 idiom 미러 — 브랜드 player·파형·위치저장 X). 단일 previewId state 라 한 번에 한 행만 펼쳐(다른 행 [미리듣기] → 이전 audio 언마운트 → 재생 중단) 다중 동시 재생 방지. audioUrl 빈 문자열(트림 후 길이 0)이면 [미리듣기] 버튼 미노출(방어 — DTO 상 string 이지만 런타임 누락 대비). 기존 예약 목록(취소 #092 · 페이지네이션 #102) 회귀 0. 신규 단위 테스트 5종(토글 노출·<audio> 마운트 + src/controls/aria-expanded·재클릭 접힘·타 행 펼침 시 이전 audio 언마운트·빈 audioUrl 미노출). 새 env var 0 · 마이그레이션 0. | |
BE: V33 ALTER TABLE store ADD COLUMN commercial_cycle_songs INT (null=HQ default 사용, non-null=매장 override 1..100). Store entity 필드 + 생성 시점 init 검증. StoreRepository.findByIdAndHqId 추가. StoreMeResponse 3 필드(hqCommercialCycleSongs 본사 default · storeCommercialCycleSongs 매장 override · commercialCycleSongs effective 값 = override ?? HQ default, BE 가 계산해 점장 player 가 단일 소비). HqStoreDetailResponse.commercialCycleSongs: Int? 추가. 신규 endpoint PATCH /api/v1/hq/stores/{id}/commercial-cycle(UpdateStoreCommercialCycleRequest{commercialCycleSongs: Int?}, null=clear · 1..100=set, @Min(1) @Max(100), 204 응답). 본사 격리 강제(타 본사 매장 404 STORE_NOT_FOUND 은닉, #084 패턴). 통합 테스트 7 케이스(HqStoreDetail 9→14 · StoreMeUpdate 9→11). FE: sync-api 로 generated 타입 자동 갱신. apps/space store-player-client commercialCycleSongs 단일 소비로 단순화(분기 로직 X). /admin/stores/[id] 에 CM 사이클 빈도 섹션 신규(라디오 default/override + override 모드 시 number input 1~100 + 검증 disabled + helper · useUpdateStoreCommercialCycle mutation · 성공 시 store detail invalidate + success Banner · 실패 매핑 401/403/404/5xx/BACKEND_UNREACHABLE/기타). 5 테스트 케이스(초기 상태 default·override / PATCH 호출 양방향 / 범위 외 disabled). docs 5종 갱신(account-settings·store/player·api-catalog/dtos·data-schema V33·홈 index). 새 env var 0 · 마이그레이션 V33 단일 ADD COLUMN(metadata-only·즉시 완료·역방향 reversible·backfill 0). 잔여 F: F1 라운드로빈 정책(#094 F3) · F2 매장 단위 CM 광고 풀 · F3 시간대별 빈도 변동 · F4 audit 누적. | |
/store/profile) | SPEC #087 완료 — 점장 본인 매니저 계정 이름 편집 + 프로필 페이지 신규. BE: PATCH /api/v1/store/me(UpdateStoreMeRequest{name?: string | null}, null/생략=미변경·non-null 1StoreMeResponse 재사용. email/password 변경 금지. FE: apps/space /store/profile 2 섹션 — 본인 정보 read-only(매장명·유형·상태·소속 본사·요금제·주소·청구 기준일·생성 시각 8행 DescCard) + 본인 매니저 이름 편집 form(input + [저장], 1/admin/settings 정확 미러 — StoreMeResponse.name=매장명 ≠ UpdateStoreMeRequest.name=매니저 이름(의미 불일치, 본사 #085 와 동일 패턴) → 편집 input 초기값 빈 문자열. 진입점: /store Classic player 헤더 우측 [재시작] 옆 [프로필] 링크(시안 부재 atom-grounded, lucide User 아이콘). 시안 부재 → 본사 #085 DescCard idiom 정확 미러. F1 매장 정보 편집(정책 결정)·STORE_PROFILE_UPDATED). |
FE-only 슬라이스 — /store/profile 비밀번호 변경 섹션 추가. 본사 #099 정확 미러 — 같은 BFF(POST /api/auth/change-password) 호출 + reseal 로 같은 세션 유지. StoreProfileClient 본인 매니저 이름 편집 섹션 아래에 새 카드(헤더 “비밀번호 변경” · 보조 “8aria-describedby="store-profile-cp-helper" + 새/확인 input aria-invalid 분기. testid 접두 store-profile-cp-*. PageHeader subtitle 갱신(본인 정보·매니저 이름 편집·비밀번호 변경). BE 변경 0. atom-grounded(시안 부재 — 본사 #099 패턴 정확 미러). 잔여 F1 강도 인디케이터 · store_audit_log 백본 도착 후)STORE_PASSWORD_CHANGED — 공용 AuthService.changePassword role 분기·detail 없음 보안) · ChangePasswordSection 추출(본사/점장 코드 안정화 후)apps/space/src/components/change-password-section.tsx ChangePasswordSection 추출. 화면별로만 다르던 testid prefix(hq-settings/store-profile)·헤더·안내 박스·sectionClassName 은 props 주입. 두 호출부 치환·testid·동작·a11y 0 변경. 단위 테스트 change-password-section.test.tsx 추가. | |
/store/support) | SPEC #112 완료 — 점장→운영사 CS 티켓(작성·조회·댓글). BE: 신규 endpoint 4종 /api/v1/store/tickets/*(STORE_MANAGER): listStoreTickets·createStoreTicket·getStoreTicketDetail·addStoreTicketComment. 본사 CS(#086)와 동일 격리 service + view DTO 분리(StoreTicketListItem·StoreTicketListResponse·StoreTicketDetailResponse·StoreTicketCommentItem·CreateStoreTicketRequest·AddStoreTicketCommentRequest). store 격리: store_id 토큰 주체 도출·WHERE store_id=:storeId 강제·타 매장/미존재 404 TICKET_NOT_FOUND 은닉(D2)·hq_id=null(본사 화면 비노출 D1)·댓글 REPLY 만(INTERNAL 격리 D3). 초기 status=OPEN·priority=NORMAL 서버 고정(점장 미지정). category 필수(신규 TicketCategory 5종 — PLAYBACK·BROADCAST·BILLING·ACCOUNT·OTHER, V35 공용 컬럼 nullable·점장 작성 시 @NotNull). 정렬 updated_at DESC, id ASC. FE: apps/space 신규 3페이지 — 목록(/store/support, 검색·status·category 필터·페이지네이션, 4컬럼: 제목·상태·분류·작성시각·행 클릭→상세) + 작성(/store/support/new, title ≤200·body ≤5000·category select 필수·우선순위 미노출·성공 시 replace 네비) + 상세(/store/support/[id], 본문·채팅형 댓글 스레드(내 문의/운영사 답변)·답글 작성·상태/우선순위 변경 UI 없음=읽기 전용·메타 사이드). 진입점 2곳: player 헤더 [고객지원](store-player-support-link, lucide LifeBuoy) + 프로필 [운영사에 문의하기] 카드. 본사 #086 idiom 미러 + 점장 가독성(큰 폰트·버튼·텍스트 병기 배지). 시안 부재 atom-grounded(design-debt 등재). 21 신규 단위 테스트. docs 다수 갱신(store/customer-support 신규·surface-matrix·profile·tickets/index·endpoints·dtos·error-codes·data-schema V35·design-debt·pending-features·홈 index). 새 env var 0 · 마이그레이션 V35(category 컬럼 + store partial index). 운영자 식별 ✅ SPEC #113 — 운영자 백오피스 목록·상세가 store ticket 을 매장명·category 로 식별·트리아지(출처/분류 배지 + category 필터). 이로써 점장 CS end-to-end 마감(점장 작성 → 운영자 응대 루프 폐쇄). 잔여 후속: category 자동 선택(추론, FR-6.5/7.5) · 첨부파일(ticket 공통 첨부 도메인) · /close(RESOLVED→CLOSED 확인 종료)PATCH /api/v1/store/tickets/{id}/close(closeStoreTicket, store_id 격리 원자 UPDATE WHERE id AND store_id AND status='RESOLVED', 비-RESOLVED 409·미존재/타 매장 404, 200 read-back)·audit STORE_TICKET_CLOSED·apps/space 상세 메타 사이드 [확인 종료] 버튼(status=RESOLVED 일 때만). 본사 #108 RESOLVED→CLOSED 의 부분집합. · STORE_TICKET_CREATED·STORE_TICKET_COMMENT_ADDED) · 전용 시안 정합. |
| 점장 셸 (큰 버튼 · 큰 폰트) · 나머지 화면 | |
/landing) | SPEC #079 완료 — apps/space /landing 점장이 space.linkmusic.io 첫 접속 시 만나는 공개 랜딩. 헤더(wordmark + [점장 로그인]) + 히어로(h1 “매장 음악·안내방송을 한 곳에서” + 보조 문구 + 큰 CTA) + 기능 카드 4종(자동재생·안내방송·즉시방송·환경점검) + 푸터(© 2026 Chilloen · 문의·본사 매니저). 인증 분기 server-side: HQ_MANAGER→/admin·STORE_MANAGER→/store·기타 role→/login·미인증→정상 노출. 미들웨어 isPublic 에 /landing 등록. 임시 atom-grounded 레이아웃(handoff 시안 부재 — packages/ui Button + 기존 theme token 만). 외부 링크·이미지 0. F1 정식 시안 도착 후 리팩터링 예정(Store Landing) |
| 긴급 멘트 · 음악 켜기/끄기 | 3초 룰 |
| LLM 자동멘트 켜기/끄기 | |
| 40~50대 가독성 | a11y 강화 |
인프라
| 항목 | 비고 |
|---|---|
| Sentry · Vercel Analytics | 응답성 측정 |
| OpenAPI CI drift 검증 | pnpm sync-api && git diff --exit-code |
Storybook (packages/ui) | atoms 시각 카탈로그 |
| docs 자동 deploy | Vercel apps/docs sub-project |
| docs CI factcheck | /factcheck-docs 주간 |
| e2e Playwright 커버리지 확대 | login · hq-onboarding · impersonate 외 |
v1.1+ (정식 출시 후)
| 항목 |
|---|
Hq.businessRegistrationNumber 체크섬 검증 |
Hq.billingMode (FRANCHISE 직접결제 옵션) |
| Argon2id 비밀번호 해싱 |
| 매장 단위 임퍼소네이션 |
| all-device logout |
| SSO |
| 다국어 (i18n 활성) |
| 모바일 (Surface 12) |
플로우 건전성 (flow-health-review-2026-06-16)
플로우 건전성 점검(docs/planning/flow-health-review-2026-06-16.md)의 권고 추적.
| 권고 | 상태 | 비고 |
|---|---|---|
| #1 빈/에러 상태 actionable CTA (B1·N4·H1) | ✅ SPEC #118 완료 | dead-end 빈/에러 상태에 다음 행동 CTA 연결(무음 자체 제거 아님 — UX dead-end 완화). 점장 player: NO_ACTIVE_PLAYLIST·EMPTY_PLAYLIST·큐 fetch 에러 카드에 [새로고침](큐 refetch)·[고객지원 문의](/store/support/new?category=PLAYBACK 사전선택) — Store Player §dead-end CTA. 본사: 빈 대시보드(totalStores===0)·빈 매장 목록에 [매장 등록]·[안내방송 만들기] bootstrap CTA — HQ Mode. 안내방송 목록 빈 상태는 이미 생성 CTA 존재 → skip(과도 남발 회피). |
| #2 앱-내 폴링 unread 배지 (N1) | ✅ SPEC #119 완료 | CS 티켓·송출 미도달 등 페르소나별 actionable 시그널을 react-query 폴링 배지로 표면화(상단 “앱-내 unread/actionable 배지” 행 참조). 외부 알림 인프라(이메일·푸시) 0. |
| SPEC #120 완료 | FE-only(새 BE/동작 0). 본사 bootstrap 진척 체크리스트: 대시보드 #118 빈상태 CTA 를 ①매장 등록(매장 수>0)·②안내방송 만들기(안내방송 수>0) 완료/미완료 진척형으로 확장(중복 X), 두 단계 완료 시 자동 숨김. 매장 수=server 대시보드 데이터·안내방송 수=useListHqTtsAnnouncements total 파생(상태/BE 0) — bootstrap-checklist.tsx client island. 계정 전달 안내문 복사: 점장(#021)·운영자(#032) 계정 발급 성공 시 안내문 복사 단계로 전환 — 입력 email·임시 비밀번호 + 로그인 안내(점장=경로만 /login(space app 별도 origin)·운영자=${origin}/login) + “첫 로그인 변경” 안내를 한 묶음 navigator.clipboard.writeText 복사(https 전제·실패 toast+평문 폴백). 임시 비번 재노출·저장·로깅 0(D3). 공용 AccountShareSection/lib/account-share.ts. atom-grounded(design-debt 등재). 범위 밖(후속): 이메일 자동 발송(B 백로그)·본사가 점장 계정 직접 발급(#084 F5 A 백로그)·온보딩 풀 위저드. |
무음 콘텐츠 시드 — 결정: 현행 유지(C), 2026-06-16
SPEC #118 은 무음 dead-end 의 UX 연결만 닫았고, 무음 자체(콘텐츠 부재) 는 범위 밖이었다.
점장 player 폴백 체인이 활성 PL → 본사 기본 PL → 빈 큐(StorePlaybackQueueService 최종 폴백
없음)에서 끝나, 콘텐츠 미셋업 신규 매장은 무음이다(코드 검증 완료 — 의도된 동작).
결정(사용자, 2026-06-16): 현행 유지(C). 아래 A·B 둘 다 안 한다. 무음 시 점장이 [고객지원 문의](#118 CTA)로 연락하면 되고(이미 출시), 콘텐츠 셋업 보장은 운영 절차(운영사가 매장 go-live 전 PL 지정)로 둔다. 음악 백본의 데이터/로직은 정상이며 무음은 셋업 미완 신호일 뿐.
| 검토했으나 안 함 | 사유 |
|---|---|
| 라이선스·콘텐츠 비즈니스 비용 대비 불필요 — 연락 안내로 충분 | |
| 강제 게이트 대신 운영 절차로 처리 |
References
- 각 SPEC
7. 다음 SPEC 미리보기섹션 - handoff
10/11/12-surface-*.md§4 (Page inventory) docs/planning/flow-health-review-2026-06-16.md(권고 #1~#3)