DomainData Schema (ER)

Data Schema (ER 다이어그램)

기준 Flyway V1~V7 + 후속. linkmusic-msa-space-was/src/main/resources/db/migration/.

ER 다이어그램 (현재 도입)

HQ_PRIVACY_CONSENT, STORE_PRIVACY_CONSENT 는 위 HQ_CONSENT / STORE_CONSENT 와 동일 schema. MUSIC 는 음원 도메인 첫 테이블(#041) — 다른 도메인과 FK 없는 독립 엔티티. deleted_at 을 실제 채우는 첫 도메인(#042 소프트삭제, status 축 없음).

Flyway Migration 흐름

Version파일내용
V1V1__init.sql초기 (SPEC #001 bootstrap). 빈 schema 확인 + helper
V2V2__add_hq_store.sqlhq, store CREATE + 가상 본사 INSERT + partial unique
V3V3__auth_and_audit.sqloperator_account · refresh_token CREATE + hq·storecreated_by·updated_by ALTER + 첫 OPERATOR (dev@chilloen.com) 시드
V4V4__terms_and_consent.sqlterms_document·privacy_policy·4개 consent table CREATE + partial unique
V5V5__impersonation_token.sqlimpersonation_token CREATE (SPEC #005)
V6V6__store_manager_optional.sql(#011) Store 가입 시 manager 정보 nullable 강화
V7V7__prod_operator_seed.sql(#008) prod-only seed — prod@chilloen.com
(#018·#024·#027·#032·#033·#037·#039 등 후속 마이그레이션)HqStatus 전이·정지 사유·CS 티켓·운영자 계정·약관 effectiveAt·매장 전이/폐점 등
V15V15__create_music.sql(#041) music 테이블 CREATE — 음원 도메인 첫 테이블. id(uuid pk, 앱 선할당) · title · audio_url · duration_seconds · created_at/updated_at · created_by/updated_by · deleted_at(soft-delete) + idx_music_created_at (created_at DESC) · idx_music_active (id) WHERE deleted_at IS NULL. audit enum(MUSIC_*·MUSIC)은 VARCHAR 영속이라 마이그레이션 불필요. #042 조회·소프트삭제도 V15 재사용(인덱스·deleted_at·MUSIC_DELETED enum 모두 기존 자원)
V16V16__create_library.sql(#053) library·library_music 테이블 CREATE — 음원→라이브러리 2계층 중간 층. library(id · name · library_type(AI/TRUST) · deleted_at soft-delete) + idx_library_created_at·idx_library_active. library_music(library_id FK→library · music_id FK→music) + unique(library_id,music_id)(할당 멱등 근거) · idx_library_music_library. 상세는 Library · LibraryMusic
V17V17__create_playlist.sql(#054) playlist·playlist_library 테이블 CREATE — 음원→라이브러리→플레이리스트 2계층 최상위 층. playlist(id · hq_id FK→hq · name · deleted_at soft-delete) + idx_playlist_hq·idx_playlist_active. playlist_library(playlist_id FK→playlist · library_id FK→library · position) + unique(playlist_id,library_id)(담기 멱등 근거) · idx_playlist_library_playlist. 상세는 Playlist · PlaylistLibrary
V18V18__add_store_active_playlist.sql(#055) store.active_playlist_id 컬럼 ADD — 음악 2계층의 매장 적용 층. store 에 nullable FK active_playlist_id(→playlist) 추가 + partial index idx_store_active_playlist (active_playlist_id) WHERE active_playlist_id IS NOT NULL. NULL=미적용·단일 활성. 상세는 Store Active Playlist
V19V19__playlist_is_default.sql(#058) playlist.is_default 컬럼 ADD — 본사 기본 PL. is_default BOOLEAN NOT NULL DEFAULT false + partial unique index uq_playlist_default (hq_id) WHERE is_default AND deleted_at IS NULL(본사당 1개). 기존 행 default false·새 env 없음. 운영사가 지정/해제(setDefaultPlaylist/clearDefaultPlaylist), 점장 큐가 매장 활성 PL 부재 시 본사 기본 PL 로 fallback(source=DEFAULT, 비파괴·store row 미변경). 상세는 Playlist
V20V20__add_music_source.sql(#059) music.music_source 컬럼 ADD — 음원 타입(AI/TRUST). 업로드 시 지정·불변. 라이브러리 library_type 과 동일 도메인이며 library_music 할당 시 일치를 강제(불일치 400 LIBRARY_TYPE_MISMATCH). 기존 행 backfill 정책은 BE 마이그레이션 결정. 상세는 Music · MusicSource enum
V21V21__create_tts_announcement.sql(#061) tts_announcement 테이블 CREATE — 본사 TTS 안내방송. id(uuid PK = blob 파일명, 선생성) · hq_id FK→hq · title · text · voice(TtsVoice) · audio_url · duration_seconds? · Auditing · deleted_at soft-delete. partial index WHERE deleted_at IS NULL + idx(hq_id, created_at DESC). 합성=Typecast→Azure blob({prefix}/tts/{id}.mp3). 상세는 HQ TTS 안내방송 · TtsVoice enum
V23V23__create_announcement_dispatch.sql(송출 슬라이스) announcement_dispatch 테이블 CREATE — 본사→매장 송출 fan-out. id(uuid PK = dispatchId) · announcement_id FK→tts_announcement · hq_id FK→hq · store_id FK→store · status(DispatchStatus PENDING/PLAYED/CANCELED, SPEC #077 3종 확장 — 마이그레이션 없이 enum 값 추가) · created_at · played_at?. 매장당 1 row · 중복 송출 허용(unique 제약 없음) · ack 은 원자 조건부 UPDATE(WHERE status='PENDING', 첫 ack 만 204·이후 404). 본사 취소도 동일 패턴(WHERE id AND hq_id AND status='PENDING', 1행=204·0행=404). idx(store_id, status, created_at)(점장 pending 폴링 — status='PENDING' 만 매치하므로 CANCELED 자동 제외). 상세는 AnnouncementDispatch · DispatchStatus enum
V24V24__tts_announcement_source.sql(즉시방송 슬라이스) tts_announcement.source · created_by_store_id 컬럼 ADD — TTS 안내방송의 출처 구분. source(TtsAnnouncementSource HQ/STORE_BROADCAST, NOT NULL, 기존 행 backfill HQ) · created_by_store_id(uuid FK→store, NULL — STORE_BROADCAST 일 때만 채워짐). 점장 즉시방송 미리듣기(previewStoreBroadcast)가 STORE_BROADCAST draft row 를 만들고, send 가 본인 매장에 dispatch 한다. 보안 불변식: 본사-facing 안내방송 쿼리(목록·상세·수정·삭제·dispatch)는 모두 source='HQ' 로 격리해 점장이 만든 STORE_BROADCAST row 가 본사 화면·계약에 절대 노출되지 않게 한다. 상세는 TtsAnnouncement · TtsAnnouncementSource enum
V25V25__create_broadcast_template.sql(자주쓰는방송 슬라이스) broadcast_template 테이블 CREATE — 점장 자주 쓰는 방송 템플릿. id(uuid PK) · store_id FK→store · name(160) · text(1200) · voice(TtsVoice) · Auditing · deleted_at soft-delete. partial index idx(store_id, updated_at DESC) WHERE deleted_at IS NULL(목록 정렬·매장 스코프). 매장당 활성 ≤20(생성 시 count 검증, 초과 409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED). 상세는 점장 즉시방송 · BroadcastTemplate
V26V26__announcement_dispatch_history_index.sql(#065) announcement_dispatch(announcement_id, created_at DESC, id DESC) 인덱스 ADD — 본사 송출 이력 조회(listHqTtsAnnouncementDispatches)의 정렬·필터 인덱스(idx_announcement_dispatch_history). status 무관 전체 row 대상이라 partial 아님(V23 의 idx_announcement_dispatch_store_pending 은 점장 PENDING 전용으로 별개). 같은 announcement scope 안에서 created_at DESC, id DESC 결정적 정렬을 인덱스로 보장. FE 진입은 HQ TTS 안내방송 송출 이력 다이얼로그.
V27V27__hq_audit_log.sql(#067) hq_audit_log 테이블 CREATE — 본사(HQ_MANAGER) 송출 actor 감사 백본. id uuid PK · occurred_at/created_at · hq_id FK→hq(테넌트 격리) · actor_account_id FK→operator_account(HQ_MANAGER 계정) · actor_email(스냅샷) · actor_role(HqAuditActorRole HQ_MANAGER/OPERATOR_IMPERSONATING) · impersonated_by_operator_id? FK→operator_account · impersonated_by_email? · action(HqAuditAction HQ_ANNOUNCEMENT_DISPATCHED) · target_type(HqAuditTargetType TTS_ANNOUNCEMENT) · target_id? · target_label? · detail?(1024). CHECK: (actor_role='OPERATOR_IMPERSONATING') = (impersonated_by_operator_id IS NOT NULL)(정합성 강제). 인덱스: idx_hq_audit_hq_id_occurred(hq_id, occurred_at DESC, id DESC) 결정적 정렬 + idx_hq_audit_actor_account_id · idx_hq_audit_action · idx_hq_audit_target_type · idx_hq_audit_target_id. 기록 hook = HqAnnouncementDispatchService.dispatch 트랜잭션 내 HqAuditService.record 1줄(audit INSERT 실패 → dispatch 함께 롤백, 원자성). 상세는 HqAuditLog · enum 도메인 HqAuditAction ·HqAuditActorRole · HqAuditTargetType.
V28V28__announcement_dispatch_audit_id.sql(#071) announcement_dispatch.audit_id 컬럼 ADDuuid NULL + FK → hq_audit_log(id) ON DELETE SET NULL + 인덱스 idx_announcement_dispatch_audit_id(audit_id). 송출 이력 다이얼로그 행위자 컬럼(#065 F1)이 dispatch row 와 audit row 를 직접 매핑할 수 있게 하는 1:1 링크. V28 이전 row 는 백필하지 않는다(announcement_id + occurred_at 범위 백필이 부정확) — 다이얼로그·DispatchHistoryItem 의 세 actor 필드는 그대로 null 노출(폴백 ”—”). V28 이후 신규 dispatch 는 같은 트랜잭션 안에서 audit row INSERT 후 auditId 를 set 하므로(원자성, #067 패턴) 실 운영에서 null 노출은 없음. 상세는 AnnouncementDispatch · DispatchHistoryItem.
V29V29__announcement_dispatch_scheduled_at.sql(#078) announcement_dispatch.scheduled_at 컬럼 ADDtimestamp NULL. null=즉시 송출(기존 row 호환 — backfill 없음), non-null=예약 송출 시각(SCHEDULED 적재 후 백그라운드 디스패처가 도래 시 PENDING 전이). 같은 SPEC 에서 DispatchStatusSCHEDULED 값 추가(VARCHAR 영속이라 마이그레이션 없이 enum 확장 — V23 패턴 동일). partial index idx_announcement_dispatch_scheduled(status, scheduled_at) WHERE status='SCHEDULED' — 디스패처 1분 cron 의 WHERE status='SCHEDULED' AND scheduled_at <= :now 쿼리 가속(전체 row 가 아니라 SCHEDULED 만 인덱스 적재). 점장 player pending 폴링은 WHERE status='PENDING' 만 매치 → SCHEDULED 자동 제외(코드 변경 0). 상세는 HqDispatchScheduler · DispatchStatus enum.
V30V30__tts_announcement_is_emergency.sql(#082) tts_announcement.is_emergency 컬럼 ADDBOOLEAN NOT NULL DEFAULT FALSE. 점장 즉시방송(broadcast-now TTS/녹음 탭)의 긴급방송 옵션 적재용. 미아·화재·정전·분실물·응급 안전 안내 한정. 기존 row 는 모두 FALSE(안전한 default), 신규 row 는 service 가 request DTO 의 isEmergency 를 set(?: false default). 인덱스 없음 — 긴급 row 는 드물고 전용 조회 없음(점장 pending 조회는 기존 store_id+status 인덱스로 충분). 본 슬라이스는 플래그 적재 + 응답 노출까지(player 인터럽트 재생 F1·본사 audit 누적 F2 후속). 상세는 점장 즉시방송 · BroadcastPreviewRequest/BroadcastSendRequest DTOs.
V31V31__commercial_song.sql(#093) commercial_song 테이블 신설 — 본사 CM송(광고/공지 음원) 백본. 컬럼 id UUID PK · hq_id UUID NOT NULL · title VARCHAR(200) NOT NULL · audio_url TEXT NOT NULL · duration_seconds INTEGER NOT NULL · is_active BOOLEAN NOT NULL DEFAULT TRUE · created_at·updated_at TIMESTAMPTZ NOT NULL · deleted_at TIMESTAMPTZ(soft-delete). partial index idx_commercial_song_hq_active(hq_id, is_active) WHERE deleted_at IS NULL — 본사 격리 쿼리(WHERE hq_id = :hqId AND deleted_at IS NULL)와 F1 후속의 활성 row 집계 가속(전체 row 가 아니라 활성만 인덱스 적재). 본 슬라이스는 백본 + 본사 관리 UI 만(점장 player 사이클 삽입 F1·CM 라이브러리 묶음 F2·audit F3 후속). 상세는 HQ Mode CM송 관리 · HqCommercial DTOs.
V32V32__hq_commercial_cycle_songs.sql(#095) hq.commercial_cycle_songs 컬럼 ADDINT NOT NULL DEFAULT 5. 본사 CM송 사이클 빈도(N곡마다 1회). 점장 player 가 StoreMeResponse.hqCommercialCycleSongs 로 받아 음악 곡 ended 카운터 임계치로 사용한다(#094 의 모듈 상수 SONGS_BETWEEN_COMMERCIALS=5 를 본사 설정값으로 동적화 · 도착 전 또는 0 이면 fallback 5). 본사 설정 페이지 /admin/settings 의 [CM송 사이클 빈도] 섹션(UpdateHqMeRequest.commercialCycleSongs 1~100)으로 편집. 기존 본사 row 는 DEFAULT 로 5 적재(backfill 없음). 인덱스 없음 — 본사 단위 read 이고 getHqMe/getStoreMe 가 PK lookup 으로 매번 1행만 읽는다. 상세는 HQ Mode 계정 설정 — CM 사이클 빈도 편집 · Store Player CM 사이클 · HqMeResponse DTO · StoreMeResponse DTO.
V33V33__store_commercial_cycle_songs.sql(#103, #094 F2 마감) store.commercial_cycle_songs 컬럼 ADDINT NULL. 매장별 CM 사이클 빈도 override. null = 본사 default(V32) 사용 · non-null(1..100) = 매장 override 적용. 점장 player 는 StoreMeResponse.commercialCycleSongs(effective 값 = storeCommercialCycleSongs ?? hqCommercialCycleSongs, BE 가 계산) 를 단일 소비. 본사가 산하 매장 상세 /admin/stores/[id] CM 사이클 섹션에서 PATCH /api/v1/hq/stores/{id}/commercial-cycle(UpdateStoreCommercialCycleRequest{commercialCycleSongs: Int?}) 로 편집. 기존 매장 row 는 NULL 적재(backfill 없음 — 동작 변화 0, HQ default 그대로 사용). PostgreSQL ADD COLUMN without DEFAULT = metadata-only, 즉시 완료. 인덱스 없음 — store 단위 read, PK lookup.
V34V34__store_last_commercial_song_id.sql(#104, #094 F3 마감) store.last_commercial_song_id 컬럼 ADDUUID NULL + FOREIGN KEY ... REFERENCES commercial_song(id) ON DELETE SET NULL. 매장별 CM 라운드로빈 커서. null = 라운드로빈 시작 전(신규 매장 또는 마지막 CM 이 hard-delete 됐을 때). getStoreNextCommercialWHERE id > :last AND is_active=TRUE AND deleted_at IS NULL ORDER BY id ASC LIMIT 1 로 다음 후보를 찾고, 0건이면 wrap-around 로 첫 CM(ORDER BY id ASC LIMIT 1)을 반환한다. 반환 직전 같은 트랜잭션 dirty-checking 으로 lastCommercialSongId = nextId 갱신. SPEC #094 의 ORDER BY RANDOM() LIMIT 1 을 결정적 라운드로빈으로 대체 — 같은 CM 연속 반환·1건도 안 나오는 분포 문제 해소. FK ON DELETE SET NULL 로 referenced CM hard-delete 시 자동 clear → 다음 호출이 wrap-around 로 회복. PostgreSQL ADD COLUMN without DEFAULT = metadata-only. 인덱스 없음 — store PK lookup. 상세는 Store Player CM 사이클 · HQ Mode CM송 관리 — 점장 player 자동 재생.

| V35 | V35__ticket_store_category.sql | (#112) 점장 CS 티켓 격리 + category — 공용 ticket 테이블(V11)에 category VARCHAR(20) NULL 컬럼 ADD(신규 TicketCategory enum — PLAYBACK·BROADCAST·BILLING·ACCOUNT·OTHER) + partial index idx_ticket_store_id(store_id) WHERE store_id IS NOT NULL(idx_ticket_hq_id 본사 partial 미러 — 점장 격리 조회 WHERE store_id = :storeId 가속). store_id UUID 컬럼 + FK 는 V11 에 이미 존재(주체 컬럼 마이그레이션 불필요). category nullable = 기존 운영자/본사 티켓 하위호환(점장 작성은 필수 @NotNull). 점장 티켓은 store_id 만 채우고 hq_id=null(본사 화면 비노출 — 과노출 차단). ⚠️ hq_id=null 정책은 #173(V56)에서 변경 — 점장 티켓도 소속 본사 hqId 를 갖고, 본사가 하위 매장 CS 로 조회·처리한다(아래 V56). PostgreSQL ADD COLUMN without DEFAULT = metadata-only. 상세는 Store Customer Support · Store CS Ticket DTOs. | | V56 | V56__backfill_ticket_hq_id.sql | (#173) 점장 티켓 hq_id 백필 — ticket.hqId 의미 확장 — 스키마 컬럼 변경 없음(ticket.hq_id 는 V2 부터 nullable 존재). 의미만 확장: 기존엔 본사발 티켓만 hqId 보유(#112 과노출 차단으로 점장 티켓 hq_id=null), 이제 점장 티켓도 소속 본사 hqId 보유. 백필 UPDATE SET hq_id = s.hq_id FROM store s WHERE t.store_id = s.id AND t.store_id IS NOT NULL AND t.hq_id IS NULL(기존 점장 티켓에 소속 본사 채움 — 본사발 티켓은 이미 hqId·storeId=null 이라 무영향). 신규 점장 티켓은 StoreTicketService.createStoreTicket 이 생성 시 hq_id=store.hqId 세팅. 본사발/매장발 구분 = storeId null 여부(본사발 storeId=null·매장발 storeId≠null). 기존 본사 CS(listHqTickets 등, #086)엔 storeId IS NULL 가드 추가로 백필 후에도 매장 티켓이 안 샌다(#173 D4 — 본사발 격리 보존). 하위 매장 CS 는 별도 endpoint /api/v1/hq/store-tickets/*. 상세는 HQ 하위 매장 CS · HQ 하위 매장 CS DTOs. | | V37 | V37__create_music_tag_option.sql | (#132) music_tag_option 테이블 CREATE — 장르·무드 태그 옵션. id uuid PK · type VARCHAR(MusicTagOptionType GENRE/MOOD) · value VARCHAR(50) · sort_order INT NOT NULL DEFAULT 0 · active BOOLEAN NOT NULL DEFAULT true(soft-delete 플래그) · created_at/updated_at. unique(type, value) — 같은 타입 내 value 중복 차단(409 MUSIC_TAG_OPTION_DUPLICATE 근거). 목록은 페이지네이션 없이 sort_order ASC, value ASC 결정적 정렬·비활성(active=false) 포함(운영자 관리 화면). 삭제는 soft delete(active=false, 멱등) — 기존 음원 참조 보존. type 은 생성 후 불변. 운영사 UI(/settings/music-options CRUD)는 설정 18-3. 상세는 music_tag_option · enum MusicTagOptionType. | | V38 | V38__hq_ducking_config.sql | 본사 더킹(ducking) config defaulthqduck_enabled BOOLEAN NOT NULL DEFAULT true · duck_volume_percent INT NOT NULL DEFAULT 20 · duck_fade_ms INT NOT NULL DEFAULT 400 3컬럼 ADD. 멘트(안내방송·CM) 송출 중 배경음악을 정지하지 않고 볼륨만 감쇠→복원하는 점장 player 동작의 산하 매장 기본값. V32 commercial_cycle_songs 미러(본사 default + 매장 override 위계). 검증은 application 단(volume 0100·fade 05000). 기존 본사 row 는 DEFAULT 로 적재(backfill 없음). Hibernate 가 DDL DEFAULT 를 무시하므로 entity 기본값도 함께(backend.md #5). 인덱스 없음getHqMe/getStoreMe PK lookup. 본사 /admin/settings 더킹 default 섹션(PATCH /api/v1/hq/me/ducking전체 replace)으로 편집. 상세는 HQ Mode 계정 설정 — 더킹 default · UpdateHqDuckingRequest DTO. | | V39 | V39__store_ducking_config.sql | 매장별 더킹 per-field overridestoreduck_enabled BOOLEAN NULL · duck_volume_percent INT NULL · duck_fade_ms INT NULL 3컬럼 ADD(각 nullable). V33 store.commercial_cycle_songs 미러. 각 필드 null = 본사 default(V38) 사용 · non-null = 매장 override(volume 0..100·fade 0..5000). 점장 player 는 StoreMeResponse.duckEnabled/duckVolumePercent/duckFadeMs(effective = 각 필드 override ?? HQ default, BE 계산) 단일 소비. 본사가 산하 매장 상세 /admin/stores/[id] 더킹 override 섹션에서 PATCH /api/v1/hq/stores/{id}/ducking(UpdateStoreDuckingRequestper-field null=override 제거, 전체 replace) 로 편집. 기존 매장 row 는 NULL 적재(backfill 없음 — 본사 default 자동 사용). PostgreSQL ADD COLUMN without DEFAULT = metadata-only. 인덱스 없음 — store PK lookup. 상세는 HQ Mode 매장 상세 — 더킹 override · UpdateStoreDuckingRequest DTO. | | V55 | V55__create_playback_tables.sql | (#172) 매장 음악 재생 가시성 3테이블 CREATE — 순수 추가 스키마(기존 무영향·리스크 0). play_log(곡 단위 전량·신탁 신고 근거·append-only): id·store_id·hq_id·music_id·library_id?·music_source(MusicSource enum)·is_trust(bool = music_source==TRUSTstarted_at(tz)·played_ms(int)·created_at. 멱등 unique (store_id, music_id, started_at)(재전송 중복 방어) + 인덱스 (store_id, started_at)·(hq_id, started_at)·(is_trust, started_at). FK store/hq/music/library. store_playback_status(매장 현재 상태 1행·heartbeat upsert): store_id(PK)·hq_id·state(PLAYING/PAUSED/SILENT/OFFLINE 파생current_music_id?·last_heartbeat_at·updated_at. playback_daily_rollup(매장×일 집계): (store_id, day_kst) PK·hq_id·played_ms_total·played_ms_trust·track_count·track_count_trust·updated_at. play_log 적재 시 upsert(일/주/월 조회·준수율 기반). 적재는 POST /api/v1/store/playback/report(FE-A), 조회는 GET /api/v1/hq/dashboard/playback-status(FE-B). 상세는 Playback Visibility 테이블 · PlaybackReportRequest DTO. | | V57 | V57__store_device.sql | (SPEC #178) store_device 테이블 CREATE — 매장 기기(PC) 등록. id·store_id FK→store·device_key VARCHAR(64, 클라 발급 UUID·localStorage)·label VARCHAR(50, CHECK non-blank)·last_seen_at·auditing·deleted_at(회수 = soft-delete). uq_store_device_store_key_active (store_id, device_key) WHERE deleted_at IS NULL(등록 멱등의 최종 방어 — 회수분 제외라 재등록 가능) + idx_store_device_store_active (store_id, created_at) WHERE deleted_at IS NULL. 상한 4대는 애플리케이션 판정(409 DEVICE_LIMIT_EXCEEDED) — 설정 가능해야 하므로 DB CHECK 로 박지 않는다. 순수 추가 = 빈 테이블 = 전 기기가 종전처럼 매장 단위로 동작(D12). 상세는 매장 기기 · Store Devices. | | V58 | V58__store_device_playlist.sql | (SPEC #178 D3) store_device_playlist 테이블 CREATE — 기기별 활성 PL. device_id uuid PK FK→store_device·playlist_id uuid FK→playlist nullable(NULL = 명시적으로 매장 기본을 따름)·applied_at·created_at/updated_at + idx_store_device_playlist_playlist (playlist_id) WHERE playlist_id IS NOT NULL. 큐 해석 순서가 기기 지정 → 매장 활성 PL → 본사 기본 PL → 없음 4단이 된다(기존 2단 폴백 위에 1단 추가). store.active_playlist_id그대로 유지(D13 — 구버전·기기 미지정 폴백 소스). 상세는 Store Active Playlist. | | V59 | V59__play_log_device.sql | (SPEC #178 D5) 재생 로그·재생 상태의 기기 분리 — (1) play_log.device_id uuid nullable FK→store_device ADD + 멱등키 재구성: 종전 uq_play_log_store_music_started DROP 후 partial unique 2개로 분할 — WHERE device_id IS NULL(store_id, music_id, started_at)(구버전 보고의 매장 단위 멱등 보존) · WHERE device_id IS NOT NULL(device_id, music_id, started_at)(다른 기기의 같은 곡·같은 시각은 서로 다른 재생). Postgres 는 NULL 을 서로 다른 값으로 보므로 단일 unique 에 device_id 를 넣으면 구버전 행의 방어가 사라진다. (2) store_device_playback_status CREATE(device_id PK·store_id·hq_id·state·current_music_id?·last_heartbeat_at·updated_at + idx_sdps_store·idx_sdps_hq_heartbeat) — 기존 store_playback_status(매장 PK)는 그대로 두고 조회 시 두 소스를 병합(무중단 배포 안전). | | V60 | V60__dispatch_device_ack.sql | (SPEC #178 D11) dispatch_device_ack 테이블 CREATE — 방송 기기별 ack. PRIMARY KEY(dispatch_id, device_id)·outcome VARCHAR(16, 기존 DispatchAckOutcome 재사용)·acked_at·updated_at + idx_dda_dispatch. dispatch 는 매장 단위 유지(본사 이력 UI 변경 최소화)하고 기기별 결과만 자식 테이블에 쌓는다. 종전엔 ack 가 원자 조건부 UPDATE 라 첫 기기만 204·나머지 404 였다. 종착: 하나라도 PLAYED → PLAYED · 활성 기기 전부 실패/폐기 → 기존 실패 경로 · 무-ack → 기존 grace 만료 cron. | | V61 | V61__dispatch_play_at.sql | (SPEC #178 D9·D10) announcement_dispatch.play_at TIMESTAMPTZ nullable ADD — 절대 재생 시각. 종전엔 각 기기가 20초 폴링으로 독립 수신해 같은 방송이 최대 20초까지 어긋났다(스피커 분리 매장에서 에코). 서버가 시각을 지정하고 각 기기가 서버-클라 오프셋을 보정해 그 시각에 시작한다(프리페치 동반·기대 오차 수십 ms). NULL 이면 종전대로 즉시 재생(하위호환·구버전 클라이언트는 필드를 무시). 인덱스 없음 — 조회는 store_id·status 기준이고 play_at 은 payload 로만 쓰인다. | | V62 | V62__dispatch_play_at_backfill_and_index_cleanup.sql | (SPEC #178 리뷰 교정) play_at 백필 + 중복 인덱스 제거 — 적용된 마이그레이션 본문을 고치면 Flyway checksum 이 깨지므로 교정은 항상 새 버전 파일로 한다. (1) 백필 UPDATE announcement_dispatch SET play_at = scheduled_at WHERE status='SCHEDULED' AND play_at IS NULL AND scheduled_at IS NOT NULL — 이미 적재된 미래 예약도 동시 재생 혜택을 받게(종착한 과거 row 는 의미 없고 PENDING 은 지금 넣으면 오히려 과거 시각이라 대상 아님). 코드도 세 곳을 함께 고쳤다: transitionScheduledIdsToPendingplay_at = COALESCE(play_at, scheduled_at)(모든 예약이 반드시 지나는 단일 지점 — 생성 경로가 늘어도 자동으로 붙는다) · OccurrenceMaterializer(반복 예약 전개) · StoreBroadcastService.send(점장 예약). (2) DROP INDEX idx_dda_dispatch — V60 의 이 인덱스는 PRIMARY KEY(dispatch_id, device_id) 선행 컬럼과 완전히 겹쳐 조회 이득 0·쓰기 비용만 늘렸다. (3) V59 주석 정정 — 당시엔 store_device_playback_status읽는 코드가 없어 expand 단계였고, 조회 병합은 V63 슬라이스에서 닫혔다(아래). | | V63 | V63__announcement_dispatch_target_device.sql | (SPEC #178 D8 통합 검토) announcement_dispatch.target_device_id uuid nullable FK→store_device ADD — “점장 즉시방송 = 누른 PC 1대만”을 DB 차원에서 강제. NULL = 매장 전 기기 대상(본사 즉시·본사 예약·점장 예약·반복 전개 — 종전 전부 이 값) · non-NULL = 그 기기 1대 전용. nullable 이라 기존 row 는 전부 “전 기기”로 해석 = 백필 불요·동작 변화 0, 구버전 클라이언트(deviceId 미전송)는 IS NULL 인 것만 보므로 하위호환 유지. 종전 교정은 기기 인지 pending 조회의 PLAYED 가지에만 play_at IS NOT NULL 을 걸었는데 dispatch 는 생성부터 첫 ack 까지 PENDING 이라 그 사이 나머지 PC 가 각자의 20초 폴링 tick 에 같은 row 를 가져갔다(15초 멘트면 15초 창) → 매장 4대에서 최대 20초씩 어긋나 반복 재생. 대상 기기는 생성 시점에 확정된 사실이므로 상태·파생 컬럼으로 추정하지 않고 row 가 직접 든다. partial index idx_announcement_dispatch_target_device (target_device_id) WHERE target_device_id IS NOT NULL(지정 방송은 소수라 인덱스를 작게 유지). | | V64 | V64__announcement_dispatch_store_played_index.sql | (SPEC #178) 기기 인지 pending 조회의 PLAYED 가지 partial indexidx_announcement_dispatch_store_played (store_id, played_at) WHERE status='PLAYED' AND play_at IS NOT NULL. PENDING 가지는 V23 idx_announcement_dispatch_store_pending (store_id, created_at) WHERE status='PENDING' 이 받지만 PLAYED 가지를 받쳐줄 인덱스가 없어(store_id 로 시작하는 비-partial 인덱스조차 없었다) planner 가 seq scan 으로 떨어질 수 있었다. 이 테이블은 반복 예약 전개로 무한 증가하고 만료돼도 삭제되지 않는데 매장 100개 × 기기 4대 × 20초 폴링 = 분당 1,200회 돈다(커넥션 고갈 사고 이력이 있는 레포라 폴링 경로의 seq scan 은 곧 장애). partial 조건을 쿼리의 PLAYED 가지와 정확히 일치시키고, 정렬 컬럼(created_at·id)이 아니라 필터 컬럼(played_at)을 뒤에 둔다(played_at >= :since 범위로 후보를 줄이는 게 본질이고 남은 소수의 정렬은 메모리에서 끝난다). 함께 애플리케이션 쿼리도 OR 하나를 두 개의 index scan 으로 분해하고 결과를 합쳐 created_at ASC, id ASC 로 재정렬한다(OR 로 두면 두 partial index 를 동시에 살리기 어렵다). | | V65 | V65__play_log_store_music_started_device_index.sql | (SPEC #178) play_log 이중 적재 대칭 가드용 보조 인덱스idx_play_log_store_music_started_device (store_id, music_id, started_at) WHERE device_id IS NOT NULL. V59 의 두 partial unique 는 완전히 disjoint 라 같은 재생이 두 행으로 들어갈 수 있고, 한쪽 방향(구버전 row 선행 + 기기 보고 후행)만 insertIfAbsentForDevice 가드가 막고 있었다. 반대 방향에도 실제 경로가 있다: 기기 D 보고 → 응답 유실로 FE 로컬 큐 잔류 → 그사이 기기 D 회수 → 재전송 시 StorePlaybackReportService 가 무효 기기 id 를 조용히 null 로 접어 매장 단위 경로 진입 → row 2건 + playback_daily_rollup 2배 가산(신탁 신고·월 청구 근거라 정확도가 곧 계약 리스크). insertIfAbsentWithoutDeviceNOT EXISTS (같은 store·music·started_at 의 device_id IS NOT NULL row) 대칭 가드를 넣었는데, uq_play_log_device_music_started 는 선행 컬럼이 device_id 라 이 조건을 커버하지 못하므로 보조 인덱스를 둔다(매 재생 보고마다 평가되는 조건이라 미커버 시 즉시 seq scan). 매장 전체가 아니라 같은 키만 보므로 다른 구버전 PC 의 정상 동시 재생은 삼키지 않는다. | | V36 | V36__store_audit_log.sql | (#114) store_audit_log 테이블 CREATE — 점장(STORE_MANAGER) 액션 감사 백본. hq_audit_log(V27)의 1:1 미러본 — 테넌트 스코프 키만 hq_idstore_id 로 바뀐다(actor 컬럼·enum·임퍼소네이션 정합성 동일). id uuid PK · occurred_at/created_at · store_id FK→store(테넌트 격리) · actor_account_id FK→operator_account(점장 계정) · actor_email(스냅샷) · actor_role(StoreAuditActorRole STORE_MANAGER/OPERATOR_IMPERSONATING) · impersonated_by_operator_id? FK→operator_account · impersonated_by_email? · action(StoreAuditAction 5종 — STORE_TICKET_CREATED·STORE_TICKET_COMMENT_ADDED·STORE_PROFILE_UPDATED·STORE_PASSWORD_CHANGED·STORE_DISPATCH_CANCELED) · target_type(StoreAuditTargetType SUPPORT_TICKET·STORE_MANAGER·DISPATCH) · target_id? · target_label? · detail?(1024). CHECK: (actor_role='OPERATOR_IMPERSONATING') = (impersonated_by_operator_id IS NOT NULL)(V27 미러 정합성 강제). append-only — UPDATE/soft-delete 경로 없음(불변성을 schema 로 강제 — updated_at·deleted_at 컬럼 없음). 인덱스: idx_store_audit_store_id_occurred(store_id, occurred_at DESC, id DESC) 결정적 정렬 + idx_store_audit_actor_account_id · idx_store_audit_action · idx_store_audit_target_type · idx_store_audit_target_id(V27 미러). 기록 hook 4(같은 트랜잭션·audit 실패 시 본작업 롤백) — 점장 ticket 생성/댓글(StoreTicketService, #112)·프로필 수정(StoreMeService, #087 F4·멱등 no-op 생략)·비밀번호 변경(공용 AuthService.changePassword role=STORE_MANAGER 분기, #100 F2)·예약 송출 취소(StoreBroadcastService.cancel, #092 F2). v1 = 기록만 — 조회 view(운영자/본사) 는 후속(D5). 상세는 StoreAuditLog · enum 도메인 StoreAuditAction · StoreAuditActorRole · StoreAuditTargetType. | | V66 | V66__ticket_attachment.sql | (BE PR #324) ticket_attachment 테이블 CREATE — CS 티켓 첨부파일 백본(4 채널 공용). 공용 ticket(V11) 자식으로, 첨부는 티켓 본문(댓글 미지정) 또는 특정 댓글(업로드 query commentId)에 매달린다. 파일 본문은 DB 가 아니라 blob 스토리지(음원·CM·녹음과 동일 인프라)에 저장하고 public URL 을 노출하지 않는다 — 다운로드는 채널별 인증 endpoint 가 스토리지에서 읽어 stream 한다(Content-Disposition: attachment + RFC5987). 제약: 파일당 10MB · 티켓당 5개(초과 409 TICKET_ATTACHMENT_LIMIT_EXCEEDED) · MIME 5종(image/png·image/jpeg·image/gif·image/webp·application/pdf) + 매직바이트 검증(확장자만 바꾼 파일 400) · accountId 분당 10회. API 로 노출되는 필드는 attId·commentId·fileName·contentType·sizeBytes·createdAt(+ 서버가 만드는 downloadUrl, 운영자 응답은 visibility 추가) 이며 blob key 는 응답에 없다(TicketAttachmentItem / TicketAttachmentAdminItem). 4 채널 상세 응답이 attachments[] 를 함께 내려주고(없으면 []), 운영자 응답만 INTERNAL 댓글 첨부를 포함하며 visibility(SHARED/INTERNAL)로 구분한다(BE PR #325 — 비운영자 채널에는 그 필드를 두지 않아 존재 은닉을 유지). 4MB 초과 첨부는 업로드 티켓 경로(TICKET_ATTACHMENT_* purpose)로 백엔드에 직접 올라간다. 상세는 Tickets · endpoints. | | V67 | V67__play_log_cache_hit.sql | (SPEC #179 D10) play_log.cache_hit BOOLEAN NULL ADD — 음원 전송 캐시 적중 계측. 점장 player 가 재생 보고 logs[].cacheHit 에 곡을 로컬 캐시(Cache Storage objectURL)에서 재생했는지 옵션으로 싣는다. NULL = 구버전 미전송(하위호환) — 백필·기본값 없음: 계측 도입 이전 로그와 “미보고”를 “미적중(false)“으로 오염시키지 않기 위해서다. 롤업(playback_daily_rollup) 미반영 — 적중률(원가 모델 M2, 목표 80%+)은 로그 원본 SQL 로만 실측한다(WHERE cache_hit IS NOT NULL 모수·기기별 group by). 캐시 계층 자체는 FE 앱 레벨(Store Player) — 교체=새 UUID key(D1)·Cache-Control: immutable(D2)과 한 묶음이다. | | V68 | V68__announcement_dispatch_signal_metrics.sql | (SPEC #180 D10) announcement_dispatch 신호 계측 컬럼 4개 ADDsignal_source VARCHAR(16)(신호 수신 채널 SSE/POLLING) · signal_received_at TIMESTAMPTZ(기기가 신호를 받은 시각) · playback_started_at TIMESTAMPTZ(실제 재생 시작 시각) · signal_latency_ms INTEGER(신호 지연 = signal_received_at − COALESCE(scheduled_at, created_at) · 서버 확정). 앞의 셋은 ack 옵션 필드로 클라이언트가 서버 시각 보정값을 보고하고, 지연은 서버가 ack 수신 시점에 계산해 확정 저장한다(즉시 송출은 row 생성이 곧 발행, 예약은 도래 시각이 발행 — 예약 row 는 며칠 전에 만들어져 created_at 기준이면 무의미). 전부 nullable · 백필 없음 · 롤업 미반영 — NULL 은 “폴링으로 받음”·“지연 0” 이 아니라 “보고하지 않음” 이다(구버전 클라이언트·계측 도입 이전 row. V67 play_log.cache_hit 과 같은 패턴). 클라 시각이 30초 skew·1시간 상한을 벗어나면 지연만 NULL 로 두고 원값은 보존한다. 주간 실측 전용 partial index idx_announcement_dispatch_signal_latency (signal_received_at) INCLUDE (signal_source, signal_latency_ms) WHERE signal_latency_ms IS NOT NULL — 보고분만 담아 작고, p95 집계가 index-only scan(heap 접근 0)으로 끝난다(이 테이블은 반복 예약 전개로 무한 증가하며 실측 쿼리는 운영 중 돈다). 용도 = 즉시방송 리드타임(dispatch.immediate-play-lead-seconds) 튜닝의 근거 + SSE 커버리지 실측. 계측 UPDATE 는 ack 커밋 이후 별도 트랜잭션이라 실패해도 ack 는 이미 204 다. 채널 자체는 Store Player §실시간 통지. |

핵심 unique 제약 (race condition 차단)

제약목적
terms_document(is_active) WHERE is_active=true활성 약관 1개
privacy_policy(is_active) WHERE is_active=true활성 처리방침 1개
hq(type) WHERE type='INDEPENDENT'가상 본사 1개
operator_account(email)이메일 unique
refresh_token(account_id, token_hash)중복 token 차단
hq_consent(hq_id, document_version)동일 version 중복 동의 차단
store_consent(store_id, document_version)동일 동의 차단
hq_privacy_consent / store_privacy_consent 동일

핵심 인덱스

index목적
store.hq_id본사별 매장 조회
store.last_heartbeat_at장애 알람 (TRD §7-7-8)
refresh_token(account_id, revoked_at)활성 refresh 빠른 조회
impersonation_token(account_id, used_at)활성 exchange 조회

music (#041 · #042)

음원 파일 업로드 도메인의 첫 테이블(Flyway V15). 다른 도메인과 FK 가 없는 독립 엔티티. #042 조회·삭제는 스키마 변경 없음(V16 없음) — V15 의 idx_music_created_at (created_at DESC)·idx_music_active (id) WHERE deleted_at IS NULL partial index 와 deleted_at 컬럼을 그대로 재사용한다.

컬럼타입비고
iduuid PK앱에서 시간순 UUID 선생성(이 엔티티만 @UuidGenerator 자동부여 미사용, id 선할당). key = {prefix}/{id}.mp3 로 blob 파일명 = PK (A안 — 식별자 통일). blob↔row 1:1, 매핑 컬럼 불필요
titlevarcharTIT2 재기록 값(클라 추출 제공)
audio_urlvarchar저장된 음원 url — 어댑터별 규약 문자열(Local / Azure Blob)
duration_secondsint곡 길이(초). 클라이언트 추출. DB 컬럼만 — ID3 미기록
music_sourcevarchar(#059) 음원 타입 AI/TRUST. 업로드 시 지정·불변. 라이브러리 library_type 과 일치해야 할당 가능
created_at·updated_attimestampBaseEntity (UTC)
created_by·updated_byuuidAuditorAware — 현재 OperatorAccount.id
deleted_attimestampsoft-delete (nullable). #042 가 실제로 채우는 첫 도메인
  • 소프트삭제 = deleted_at 채우는 첫 도메인 (#042) — 코드에 @SQLRestriction/글로벌 soft-delete 필터 선례가 없고(기존 “회수”는 status=WITHDRAWN 이지 deleted_at 아님), music 은 status 축 없이 단일 soft-delete 축으로만 동작한다. 자동 글로벌 필터를 도입하지 않고 repository 쿼리에서 명시적 deleted_at IS NULL 로 활성만 노출한다(목록·상세). 삭제는 원자적 UPDATE ... WHERE id=:id AND deleted_at IS NULL(affected=0 → 404, 존재 은닉). blob 은 유지(복구 가능·90일 보관 정합) — hard-purge 는 후속(F1). 음원이 deleted_at 을 실제 쓰면서 V15 의 idx_music_active partial index 가 처음 효력을 갖는다.
  • 파일 교체 = 같은 key 덮어쓰기key={id}.mp3 고정이라 교체 시 동일 blob 을 덮어쓴다(파일 이력 없음). 이력은 OperatorAuditLog row(MUSIC_FILE_REPLACED)로만 보존(F4 — 버전 보관 필요 시 {id}/{ver}.mp3 도입 검토).
  • ID3 태그 재기록·식별자 통일·카탈로그 조회·소프트삭제 흐름은 Music 참조.

music_tag_option (#132) — 장르·무드 태그 옵션

라이브러리·플레이리스트 분류에 쓰이는 장르(GENRE)·무드(MOOD) 태그 옵션(Flyway V37). 다른 도메인과 FK 없는 독립 엔티티. 운영사(OPERATOR)가 CRUD 로 관리하며, 삭제는 soft delete(active=false)로 기존 음원의 값 참조를 보존한다.

music_tag_option

컬럼타입비고
iduuid PK옵션 id
typevarcharMusicTagOptionTypeGENRE(장르)·MOOD(무드). 생성 후 불변
valuevarchar(50)옵션 값(태그명). @NotBlank @Size(1..50)
sort_orderint타입 내 표시 순서(오름차순). NOT NULL DEFAULT 0(미지정 생성 시 0)
activeboolean활성 여부. NOT NULL DEFAULT true. false = soft-deleted/비활성
created_at·updated_attimestampISO-8601
  • unique(type, value) — 같은 타입 내 동일 value 중복 차단(409 MUSIC_TAG_OPTION_DUPLICATE 근거). 목록 조회 정렬 sort_order ASC → value ASC(결정적).
  • soft delete = active=falsedeleteMusicTagOptionactivefalse 로 내린다(row 유지). 이미 비활성이어도 멱등 204. 음원이 그 value 를 참조 중이어도 안전(값 자체는 보존). 목록은 비활성 옵션도 함께 노출(운영자 관리 화면에서 재활성/구분 가능 — active=false 행은 UI 에서 흐림 + “비활성” 배지).
  • type 불변 — 수정(PATCH)은 value·sort_order·active 만 갱신, type 은 변경 불가.
  • 페이지네이션 없음 — 타입별 옵션 수가 소규모라 전체 목록을 한 번에 반환({ items }).
  • 운영사 UI(/settings/music-options 2섹션 CRUD)·계약 상세는 설정 18-3 · Music Tag Option DTOs 참조.

Library · LibraryMusic (#053) — 음원→라이브러리 2계층

음악 2계층(음원 → 라이브러리 → 플레이리스트)의 중간 층(Flyway V16). 라이브러리 = 타입(AI/TRUST) 묶음의 음원 컬렉션(OPERATOR 소유). library_music 가 음원↔라이브러리 M:N 할당을 잇는다. 플레이리스트 (라이브러리를 담음)는 Playlist · PlaylistLibrary(#054)로 도착.

library

컬럼타입비고
iduuid PK@UuidGenerator 자동부여
namevarchar라이브러리 이름 (1..255)
library_typevarcharAI(AI 생성)·TRUST(신탁). 생성 시 고정(이후 불변). 단일 타입 묶음(혼합 불가)
created_at·updated_attimestampBaseEntity (UTC)
created_by·updated_byuuidAuditorAware — 현재 OperatorAccount.id
deleted_attimestampsoft-delete (nullable). 삭제 시 채움 — 담긴 음원 할당은 유지(복구 가능)
  • idx_library_created_at (created_at DESC) · idx_library_active (id) WHERE deleted_at IS NULL.

library_music (음원 할당 M:N)

컬럼타입비고
iduuid PK
library_iduuid FK → library
music_iduuid FK → music
created_attimestamp할당 시각 — 라이브러리 곡 목록 정렬 키(DESC)
  • unique(library_id, music_id) — 중복 할당 방지(추가 멱등의 근거). idx_library_music_library (library_id).
  • 할당 멱등addLibraryMusic 은 unique 제약으로 중복 추가를 무시한다(이미 담긴 음원은 skip). removeLibraryMusic 도 멱등(담겨 있지 않아도 성공). 음원 제거 시 library_music row 만 삭제하고 음원(music) 자체는 유지한다.
  • 라이브러리 소프트삭제library.deleted_at 만 채우고 library_music 할당은 유지(복구 가능). 목록·상세는 명시적 deleted_at IS NULL 로 활성만 노출(음원 #042 패턴).
  • 음원 타입 enforcement (#059)music.music_source(AI/TRUST) ↔ library.library_type 일치를 강제한다. addLibraryMusic 시 불일치 음원이 있으면 전체 reject → 400 LIBRARY_TYPE_MISMATCH (위반 musicId 를 fields.violatingMusicIds 에 노출). F1 해소.
  • 2계층 모델·운영사 UI 상세는 Library 참조.

Playlist · PlaylistLibrary (#054) — 라이브러리→플레이리스트

음악 2계층(음원 → 라이브러리 → 플레이리스트)의 최상위 층(Flyway V17). 플레이리스트 = 라이브러리 묶음(OPERATOR 소유, hq_id 본사별). playlist_library 가 라이브러리↔플레이리스트 M:N + 순서(position)를 잇는다. 곡(music)을 직접 담지 않고 라이브러리 단위로 담는다. 매장 적용(#055)은 도착(아래 절) — status 5종 파생은 후속(F3).

playlist

컬럼타입비고
iduuid PK@UuidGenerator 자동부여
hq_iduuid FK → hq소유 본사. 생성 시 고정(이후 불변). 운영사가 본사별 PL 관리
namevarchar플레이리스트 이름 (1..255)
is_defaultboolean NOT NULL DEFAULT false(#058) 본사 기본 PL 여부. 본사당 최대 1개(partial unique index). 점장 큐 fallback 기준
created_at·updated_attimestampBaseEntity (UTC)
created_by·updated_byuuidAuditorAware — 현재 OperatorAccount.id
deleted_attimestampsoft-delete (nullable). 삭제 시 채움
  • idx_playlist_hq (hq_id) · idx_playlist_active (id) WHERE deleted_at IS NULL · (#058) partial unique uq_playlist_default (hq_id) WHERE is_default AND deleted_at IS NULL(본사당 기본 PL 1개 불변식 — soft-delete 시 자동 무효).

playlist_library (라이브러리 담기 M:N + 순서)

컬럼타입비고
iduuid PK
playlist_iduuid FK → playlist
library_iduuid FK → library
positionint담긴 순서 — 라이브러리 목록 정렬 키(ASC). reorder 로 일괄 갱신
  • unique(playlist_id, library_id) — 중복 담기 방지(담기 멱등의 근거). idx_playlist_library_playlist (playlist_id).
  • 담기 멱등addPlaylistLibrary 는 unique 제약으로 중복 담기를 무시하고(이미 담긴 라이브러리 skip) 끝에 append 한다. removePlaylistLibrary 도 멱등(담겨 있지 않아도 성공). 제거 시 playlist_library row 만 삭제하고 라이브러리(library) 자체는 유지한다.
  • 순서 변경reorderPlaylistLibraries 는 현재 담긴 라이브러리와 정확히 일치하는 전체 library_ids[] 순서를 받아 position 을 일괄 갱신한다(부분 swap 아님).
  • 플레이리스트 소프트삭제playlist.deleted_at 만 채운다. 목록·상세는 명시적 deleted_at IS NULL 로 활성만 노출.
  • 셔플 컬럼 없음 — 셔플은 점장 큐 생성 시 서버(기획서 §4-4). PL 엔티티엔 라이브러리 순서(position)만.
  • 2계층 모델·운영사 UI 상세는 Playlist 참조.

Store Active Playlist (#055) — 플레이리스트→매장 적용

음악 2계층의 매장 적용 층(StoreActivePlaylist, Flyway V18). 매장에 본사 플레이리스트 1개를 활성으로 적용한다. 단일 활성(별 테이블이 아니라 store.active_playlist_id nullable FK 컬럼, SPEC #055 D1=A안)·공유 모델(PL 참조, 복사본 아님). 적용 흐름: 음원 → 라이브러리 → 플레이리스트 → 매장 적용.

store.active_playlist_id (매장 적용 컬럼)

컬럼타입비고
active_playlist_iduuid FK → playlist, nullable매장의 활성 PL. NULL = 미적용. 단일 활성(교체 = 덮어쓰기)
active_playlist_applied_attimestamptz, nullableV52(BE #284) — 활성 PL 을 적용한 시각 전용 값. StoreActivePlaylistResponse.appliedAt 의 소스. 활성 PL 을 set/clear 할 때만 갱신되므로 매장명 변경 등 무관한 store 갱신에 끌려다니지 않는다(종전엔 store.updated_at 근사였다).
  • idx_store_active_playlist (active_playlist_id) WHERE active_playlist_id IS NOT NULL (partial index).
  • hqId 제약 — 적용 대상 PL 의 본사(playlist.hq_id)는 매장 본사(store.hq_id)와 같아야 한다. 불일치 시 409 STORE_PLAYLIST_HQ_MISMATCH(두 리소스 모두 존재하나 조합 금지). FE 는 후보 PL 을 매장 hqId 로 한정해 사전 차단.
  • 적용 = 원자적 조건부 UPDATE — 사전 조회(store·playlist 존재 + hqId 일치 + PL 활성) 후 active_playlist_id 갱신. soft-deleted PL 적용 시 404 PLAYLIST_NOT_FOUND.
  • 해제 = 멱등 NULL 세팅 — 이미 NULL 이어도 204.
  • PL 소프트삭제 시 자동 해제(D7)deletePlaylist 가 그 PL 을 활성으로 쓰던 매장의 active_playlist_id 를 bulk UPDATE 로 NULL clear(즉시 정합). 점장 큐·status 5종 파생·기본 PL fallback 은 후속.
  • 운영사 매장 적용 UI 는 Store 상세 참조.
  • 본사 조회(#057) — 음악 2계층은 “운영사 편집 · 본사 조회”가 모델 불변식. 본사 (HQ_MANAGER)는 apps/space 본사 모드에서 자기 본사 PL 목록·상세를 read-only 로 본다. 각 PL 의 적용 매장 수(active_playlist_id IN :ids AND hq_id = :hqId 카운트)를 집계해 노출 (스키마 무변경 — 기존 엔티티/레포 조합). 상세는 HQ Mode 플레이리스트 조회 참조.

시간대별 라이브러리 스케줄 (#171) — 시간표

음악 2계층 위에 시간대별로 어떤 라이브러리를 트는지를 얹는 층(Flyway V53·V54). 지금 점장 큐는 요청마다 활성 PL 전체를 셔플해 flat 큐로 내려주는데(시간 무관), SPEC #171 은 이를 “시간대별 라이브러리 스케줄”로 바꾼다. 본사 기본(운영사가 PL 마다 정함, playlist_library_schedule)을 각 점장이 자기 매장용으로 복사·수정한다(store_library_schedule, 복사 후 수정). 공통 규칙: 하루 분 오프셋(KST)·30분 해상도 (경계는 :00·:30)·[start,end) 반열림·시간대 겹침 금지. 시간표 행이 없는 PL·매장 = 현행 전체 셔플 (순수 추가 스키마 = 마이그레이션 리스크 0).

playlist_library_schedule (본사 기본 시간표, V53)

컬럼타입비고
iduuid PK
playlist_iduuid FK → playlist
library_iduuid FK → library이 구간에 재생할 라이브러리(그 PL 의 멤버)
start_minuteint구간 시작(하루 중 분, 0~1410, 30분 배수)
end_minuteint구간 종료(30~1440, 30분 배수, start<end, 반열림)
created_at·updated_attimestamptz
created_by·updated_byuuid FK → operator_account, nullableBaseEntity auditor(본사 기본은 OPERATOR 가 생성)
deleted_attimestamptz, nullablesoft-delete
  • (playlist_id, library_id) 에 unique 없음 — 여러 행 허용(재즈 10–12시 + 18–20시). 멤버십은 playlist_library 가 소유하고 이 테이블은 “그 멤버를 언제 트는가”만 소유한다.
  • ck_playlist_library_schedule_bounds CHECK — 30분 배수·0≤start<end≤1440(앱 검증 + DB 이중 안전).
  • ex_playlist_library_schedule_no_overlap EXCLUDE USING gist(playlist_id WITH =, int4range(start,end) WITH &&) WHERE deleted_at IS NULL — 같은 PL 안에서 각 30분은 최대 1개 라이브러리(D10 겹침 금지의 DB 최종 방어). btree_gist 확장 필요(CREATE EXTENSION IF NOT EXISTS btree_gist).
  • idx_playlist_library_schedule_playlist (playlist_id) WHERE deleted_at IS NULL (partial index).

store_library_schedule (점장 override 시간표, V54)

컬럼타입비고
iduuid PK
store_iduuid FK → storeoverride 소유 매장
playlist_iduuid FK → playlistoverride 대상 PL(매장 활성 PL)
library_iduuid FK → library
start_minute·end_minuteintV53 과 동일 30분 배수·반열림
created_at·updated_attimestamptz
created_by·updated_byuuid FK → operator_account, nullable점장(STORE_MANAGER)도 단일 operator_account 에 role 판별자로 저장 → FK 정합
deleted_attimestamptz, nullablesoft-delete
  • 복사 후 수정(D4) — 점장이 “커스텀 시작”하면 활성 PL 의 본사 시간표를 이 테이블로 통째 복사·시딩한 뒤 편집한다. (store_id, playlist_id) 행이 하나라도 있으면 그 PL 의 본사 시간표를 통째로 대체(부분 override 아님). 커스텀 해제 → 그 (store_id, playlist_id) 행 전부 삭제 → 본사 시간표로 복귀.
  • ck_store_library_schedule_bounds CHECK — V53 과 동일.
  • ex_store_library_schedule_no_overlap EXCLUDE USING gist(store_id WITH =, playlist_id WITH =, int4range(start,end) WITH &&) WHERE deleted_at IS NULL(store_id, playlist_id) 단위 겹침 금지.
  • idx_store_library_schedule_store_playlist (store_id, playlist_id) WHERE deleted_at IS NULL (partial index).
  • 편집 권한 위계 — 본사 기본 = 운영사(OPERATOR) 편집(apps/admin PL 상세 [시간표] 탭) · 본사 (HQ_MANAGER) 읽기 전용(apps/space PL 상세 [시간표] 탭). 매장 override = 점장(STORE_MANAGER) (apps/space /store/schedule). is_default PL 의 시간표 = 산하 매장 기본 스케줄(점장 커스텀 매장은 override 우선). 기능 상세는 Playlist 시간표 참조.

TtsAnnouncement (#061) — 본사 TTS 안내방송

본사(HQ_MANAGER)가 텍스트+voice 로 합성한 안내방송 콘텐츠(tts_announcement, Flyway V21). 합성 = Typecast TTS → Azure blob 저장(음원 #041 의 StoragePort 재사용) → 엔티티 관리. 콘텐츠 생성·보관에 더해 송출(매장 fan-out) 슬라이스가 추가됐다(announcement_dispatch, 아래). hq 와 1:N(hq_id FK).

컬럼타입비고
iduuid PK앱에서 시간순 UUID 선생성(music 패턴). key = {prefix}/tts/{id}.mp3 로 blob 파일명 = PK. blob↔row 1:1
hq_iduuid FK → hq소유 본사. 조회/삭제 시 토큰 hqId 와 일치 검증(verifyHqScope, 타 본사 404 은닉)
titlevarchar(255)안내방송 제목
texttext합성에 쓴 원문(≤1000)
voicevarcharTtsVoice(SHEAN/WOOSUNG/CYRUS/AERAN/SEUNGA)
audio_urlvarchar합성 오디오 url(Azure public blob — 직접 재생)
duration_secondsint nullable오디오 길이(초). Typecast 가 줄 때만 채워짐
created_at·updated_attimestampBaseEntity(UTC)
created_by·updated_byuuidAuditorAware
deleted_attimestampsoft-delete(nullable). 삭제 시 채움, blob 유지(복구 가능)
sourcevarchar(즉시방송 슬라이스, V24) TtsAnnouncementSource HQ/STORE_BROADCAST. 기존 행 backfill HQ. 점장 즉시방송 미리듣기가 STORE_BROADCAST draft 를 만든다
created_by_store_iduuid FK → store nullable(즉시방송 슬라이스, V24) STORE_BROADCAST 일 때만 채워짐(만든 매장). HQ source 는 NULL
is_emergencyboolean NOT NULL DEFAULT FALSE(SPEC #082, V30) 점장 긴급방송 옵션. 미아·화재·정전·분실물·응급 안전 안내 한정. preview/send 단계 request 의 isEmergency 를 set(?: false default). 기존 행 backfill FALSE. 인덱스 없음(긴급 row 드물고 전용 조회 없음). 본 슬라이스는 플래그 적재 + 응답 노출만(player 인터럽트 F1·본사 audit 누적 F2 후속)
  • partial index WHERE deleted_at IS NULL + idx(hq_id, created_at DESC)(본사별 최신순 목록).

보안 불변식(V24) — 본사-facing 안내방송 쿼리(목록·상세·수정·삭제·dispatch)는 모두 source='HQ' 로 격리한다. 점장 즉시방송이 만든 STORE_BROADCAST row 는 같은 테이블을 쓰지만 본사 화면·계약에 절대 노출되지 않는다(점장 즉시방송은 본인 매장에만 송출). 즉시방송 preview/send 흐름은 점장 즉시방송 참조.

  • 생성 = 합성 동기 완료verifyHqScopeTypecastClient.synthesize → blob 업로드 → row insert. insert/audit 실패 시 보상 삭제(orphan blob 방지). 외부 실패는 502 TTS_SYNTHESIS_FAILED · 토큰 미설정 503 TTS_TOKEN_NOT_CONFIGURED 로 격리(5xx 금지).
  • 삭제 = soft-deleteUPDATE ... WHERE id AND hq_id AND deleted_at IS NULL(affected=0 → 404 TTS_ANNOUNCEMENT_NOT_FOUND). blob·hard purge 는 후속.
  • 송출 audit (#067) — HQ_MANAGER 송출 actor 추적은 별도 hq_audit_log 백본으로 도착(아래 §HqAuditLog). operator_audit_log 확장 대신 신규 테이블 — actor 컬럼·enum·운영사 audit UI 가 OPERATOR-only 의미로 굳어 의미 오염·권한 문제 회피.
  • 화면·합성 흐름·에러는 HQ TTS 안내방송 참조.

Playback Visibility (#172) — 매장 음악 재생 가시성

본사가 비용을 부담하는데 매장이 실제로 음악을 트는지 볼 수 없던 문제를 해소하는 3테이블(Flyway V55·순수 추가 스키마·기존 무영향). 점장 player 가 재생 상태·곡 로그를 POST /api/v1/store/playback/report(FE-A)로 보고하고, 본사 대시보드가 GET /api/v1/hq/dashboard/playback-status(FE-B)로 조회한다. 신탁음원(KOMCA)은 재생 로그 전량 기록이 법적 의무이므로 play_log 가 그 근거다. 신탁 판정은 곡의 music.musicSource(intrinsic — 조인 없이 곡에서 바로 얻는 authoritative, D4).

play_log (곡 단위 전량·신탁 신고 근거·append-only)

컬럼타입비고
iduuid PK
store_iduuid, FK→store
hq_iduuid, FK→hq테넌트 스코프(본사 조회 인덱스)
music_iduuid, FK→music재생한 곡
library_iduuid nullable, FK→library재생 컨텍스트 라이브러리(선택)
music_sourceMusicSource(AI/TRUST)곡의 music.musicSource 스냅샷
is_trustboolean= (music_source==TRUST) — 신탁 신고 필터
started_attimestamptz재생 시작 시각
played_msint실제 재생 밀리초(부분·스킵 포함, 0..86400000)
created_attimestamptz
device_iduuid nullable, FK→store_device(SPEC #178, V59) 보고한 기기. NULL = 구버전 클라이언트(기기 미식별) 보고
cache_hitboolean nullable(SPEC #179 D10, V67) 이 곡을 로컬 캐시(Cache Storage)에서 재생했는지(클라이언트 보고). NULL = 미전송(구버전) — 적중률 모수 제외
  • 멱등 unique — V59 에서 partial unique 2개로 분할. 종전 uq_play_log_store_music_started (store_id, music_id, started_at) 는 기기가 여럿이면 정상 재생을 중복으로 오인했다(두 기기가 우연히 같은 곡을 같은 밀리초에 시작하면 하나가 삼켜진다). Postgres 는 NULL 을 서로 다른 값으로 보므로 단일 unique 에 device_id 를 넣으면 구버전 행의 방어가 통째로 사라진다 → 두 개로 나눈다:
    • uq_play_log_store_music_started_no_device (store_id, music_id, started_at) WHERE device_id IS NULL — 구버전 보고. 종전과 동일한 매장 단위 멱등 보존.
    • uq_play_log_device_music_started (device_id, music_id, started_at) WHERE device_id IS NOT NULL — 기기 단위 멱등. 다른 기기의 같은 곡·같은 시각은 서로 다른 재생이라 둘 다 남는다.
  • ⚠️ 두 partial unique 는 완전히 disjoint 라 이중 적재 가드가 양방향으로 필요하다(SPEC #178 통합 검토). 한쪽(구버전 row 선행 + 기기 보고 후행)은 insertIfAbsentForDeviceNOT EXISTS … device_id IS NULL 가드가 막았고, 반대 방향(기기 보고 후 그 기기가 회수되면 재전송 시 서버가 무효 id 를 null 로 접어 매장 단위 경로로 들어간다)은 insertIfAbsentWithoutDevice 의 대칭 가드(NOT EXISTS 같은 (store, music, started_at)device_id IS NOT NULL row)가 막는다. play_log 2행 + playback_daily_rollup 2배는 신탁 신고 근거이자 월 청구 근거라 정확도가 곧 계약 리스크다. 매장 전체가 아니라 같은 키만 보므로 다른 구버전 PC 의 정상 동시 재생은 삼키지 않는다.
  • 인덱스: (store_id, started_at)·(hq_id, started_at)·(is_trust, started_at)(신탁 로그 내보내기 FU-3)
    • V65 idx_play_log_store_music_started_device (store_id, music_id, started_at) WHERE device_id IS NOT NULL — 위 대칭 가드 전용(uq_play_log_device_music_started 는 선행 컬럼이 device_id 라 커버 불가. 매 재생 보고마다 평가되는 조건이라 없으면 즉시 seq scan).
  • append-only — UPDATE/soft-delete 경로 없음. raw 는 N개월 보존 후 롤업만 유지(보존 정책 미결 M1).

store_playback_status (매장 현재 상태 1행·heartbeat upsert)

컬럼타입비고
store_iduuid PK, FK→store매장당 정확히 1행
hq_iduuid, FK→hq본사 대시보드 격리 조회
statePlaybackState(PLAYING/PAUSED/SILENT/OFFLINE)PLAYING/PAUSED/SILENT 는 점장 보고값 · OFFLINE 은 서버가 last_heartbeat_at staleness(초안 7분 초과)로 파생(저장값 아님)
current_music_iduuid nullable현재 재생 곡. 무음/일시정지엔 null
last_heartbeat_attimestamptz마지막 report 시각 — OFFLINE 파생·조회의 기준
updated_attimestamptz
  • 매 report 가 이 행을 upsert(state·current_music_id·last_heartbeat_at=now). getHqPlaybackStatus 카드가 이 테이블을 가볍게 조회(60초 폴링).
  • ⚠️ 매장당 1행이라 여러 기기가 서로를 덮어썼다(본사가 새로고침할 때마다 같은 매장이 PLAYING↔PAUSED↔SILENT 를 왕복하고 현재 곡도 튀었다) → SPEC #178(V59)에서 기기 단위 테이블 store_device_playback_status추가했다. 이 테이블은 그대로 둔다(구버전 클라이언트가 계속 쓰고, PK 변경은 무중단 배포에서 위험하다) — 조회는 두 소스를 병합한다(기기 행이 있으면 그것 우선, 없으면 매장 행). 통합 검토에서 이 병합이 실제로 구현돼 라이브다(V59 가 “후속”으로 남겨둔 expand 단계 종료). 아래 매장 기기 참조.

playback_daily_rollup (매장×일 집계·일/주/월 조회 기반)

컬럼타입비고
store_iduuid, PK 일부
day_kstdate, PK 일부KST 기준 일자
hq_iduuid, FK→hq
played_ms_totalbigint그날 총 재생 시간
played_ms_trustbigint그중 신탁(TRUST) 재생 시간
track_countint그날 재생 곡 수
track_count_trustint그중 신탁 곡 수
updated_attimestamptz
  • PK (store_id, day_kst). play_log 적재 시 write-time upsert(신탁/비신탁 분리 가산). 일/주/월 요약· 준수율(FU-1/FU-2)의 조회 대상 — raw 를 매번 훑지 않아도 되게 하는 집계 레이어.

매장 기기 (#178) — 멀티 기기 분리

한 매장에서 PC 여러 대가 같은 점장 계정으로 음악을 트는데 제품에 “기기” 개념이 없어, 같은 매장의 모든 기기가 store 행 하나(활성 PL·재생 상태)와 announcement_dispatch 행 하나(방송 ack)를 공유했다. 그 결과 (1) 재생목록이 어긋나고 (2) 한 대에서 PL 을 바꾸면 다른 대도 따라갔다(고객사 제보). Flyway V57~V65 가 그 축을 분리한다 — 전부 순수 추가 스키마(빈 테이블·nullable 컬럼 = 종전 동작 = 리스크 0)이며 deviceId 를 보내지 않는 구버전 클라이언트는 계속 매장 단위 경로를 탄다(D12). 통합 검토 후속(V62~V65)이 play_at 백필 · 즉시방송 대상 기기(target_device_id) · 폴링/보고 경로 인덱스를 더했다. 기능·화면은 Store Devices.

store_device (기기 등록, V57)

컬럼타입비고
iduuid PK서버 발급. 이후 요청의 deviceId
store_iduuid FK → store
device_keyvarchar(64)클라이언트 발급 UUID(브라우저 localStorage lm.device.key). 서버는 값을 만들지 않고 받기만 한다 — 같은 브라우저 프로필이면 재방문 시 같은 키가 와서 등록이 멱등해진다
labelvarchar(50)점장이 붙이는 이름(“1층”·“카운터”). 등록 시 서버가 기기 N 으로 채움. CHECK length(btrim(label)) > 0
last_seen_attimestamptz마지막 신호(등록·재생 보고 시 갱신) — 점장이 “안 쓰는 기기”를 판단하는 근거
created_at·updated_at·created_by·updated_byBaseEntity auditing
deleted_attimestamptz, nullablesoft-delete = 회수
  • uq_store_device_store_key_active (store_id, device_key) WHERE deleted_at IS NULL — 등록 멱등의 최종 방어. 회수된 행을 제외해야 회수 후 같은 PC 가 (새 id 로) 재등록될 수 있다.
  • idx_store_device_store_active (store_id, created_at) WHERE deleted_at IS NULL — 목록·상한 카운트.
  • 상한 4대는 애플리케이션 판정(StoreDevice.DEFAULT_DEVICE_LIMIT) — 설정으로 뺄 수 있어야 하므로 DB CHECK 로 박지 않는다. 초과 시 409 DEVICE_LIMIT_EXCEEDED.
  • 서버 자동 회수 없음(D14) — 오프라인과 폐기를 구분할 수 없어 잘못 회수하면 재생 중인 기기가 끊긴다. 정리는 점장이 기기 목록에서 명시적으로 한다.

store_device_playlist (기기별 활성 PL, V58)

컬럼타입비고
device_iduuid PK, FK → store_device기기당 1행
playlist_iduuid FK → playlist, nullableNULL = “명시적으로 매장 기본을 따름”(행을 지운 것과 결과는 같지만 점장이 [본사 기본으로] 를 누른 사실이 applied_at 과 함께 남는다)
applied_attimestamptz지정 시각
created_at·updated_attimestamptz
  • idx_store_device_playlist_playlist (playlist_id) WHERE playlist_id IS NOT NULL — PL 삭제 시 참조 기기 역방향 조회.
  • 큐 해석 순서: 기기 지정 → 매장 활성 PL(store.active_playlist_id) → 본사 기본 PL → 없음. 기존 2단 폴백 위에 1단을 얹을 뿐이라 행이 없으면 종전과 완전히 동일하다.
  • store.active_playlist_id 는 유지(D13 expand→migrate→contract) — 구버전 클라이언트와 기기 지정이 없는 기기의 폴백 소스다. 제거는 전 기기 전환 후 별도 릴리스.

store_device_playback_status (기기별 재생 상태, V59)

컬럼타입비고
device_iduuid PK, FK → store_device기기당 1행
store_iduuid FK → store · hq_id uuid FK → hq매장·본사 단위 집계
statevarchar(16)PLAYING·PAUSED·SILENT(클라 보고). OFFLINE 은 조회 시 last_heartbeat_at staleness 로 파생(저장값 아님 — 매장 테이블과 동일 규칙)
current_music_iduuid, nullable, FK → music
last_heartbeat_at·updated_attimestamptz
  • idx_sdps_store (store_id) · idx_sdps_hq_heartbeat (hq_id, last_heartbeat_at) — 본사 대시보드 “지금 재생 중 / 무음 / offline” 집계.
  • 기존 store_playback_status(매장 PK)는 그대로 둔다 — 구버전 클라이언트가 계속 쓰고, PK 변경은 무중단 배포에서 위험하다.
  • 조회 병합은 라이브다(SPEC #178 통합 검토 — V59 주석이 “후속”이라고 적어둔 expand 단계가 닫혔다). HqPlaybackStatusService기기 행이 하나라도 있는 매장은 기기들로 집계하고(PLAYING > PAUSED > SILENT > OFFLINE 우선순위 = “하나라도 재생 중이면 그 매장은 재생 중”), 기기 행이 없는 매장만 종전 매장 행으로 폴백한다. 각 기기는 먼저 개별 last_heartbeat_at staleness 로 OFFLINE 파생을 거치므로 꺼진 PC 한 대가 매장 전체를 OFFLINE 으로 끌어내리지 않고 stale 한 PC 가 PLAYING 을 위조하지도 않는다. 회수된 기기의 유령 행store_device 조인 + deleted_at IS NULL 로 제외한다(이 테이블에는 soft-delete 컬럼이 없고 회수 시 행이 지워지지도 않는다). lastSeenAt = 기기들 중 가장 최근 신호 · 현재 곡 = 그 상태를 결정한 기기 중 첫 행(last_heartbeat_at DESC 정렬이라 결정적).

dispatch_device_ack (방송 기기별 ack, V60)

컬럼타입비고
dispatch_iduuid FK → announcement_dispatchPRIMARY KEY(dispatch_id, device_id)
device_iduuid FK → store_device
outcomevarchar(16)DispatchAckOutcome PLAYED·FAILED·SKIPPED 재사용
acked_at·updated_attimestamptz
  • idx_dda_dispatch (dispatch_id) — dispatch 별 기기 결과 집계(“3대 중 2대 재생”).
  • dispatch 는 매장 단위로 유지하고 기기별 결과만 이 자식 테이블에 쌓는다(본사 송출 이력 UI 변경 최소화).
  • 종착 판정: 하나라도 PLAYED → dispatch PLAYED(꺼져 있는 기기 때문에 “미도달”로 잡히면 안 된다) · 모든 활성 기기가 실패/폐기 보고 → 기존 실패 경로(재시도 누적 → 임계치 MISSED) · 아무도 ack 하지 않으면 기존 grace 만료 cron 이 MISSED 로 종결(변경 없음).

AnnouncementDispatch (송출 슬라이스) — 본사→매장 송출 fan-out

본사가 안내방송을 [송출]하면 대상 매장마다 1 row 를 만들어(announcement_dispatch) 점장 player 가 폴링·재생·ack 하는 dead-end 폐쇄 테이블. tts_announcement 와 1:N(announcement_id FK), store 와 1:N(store_id FK).

컬럼타입비고
iduuid PK송출 row id(= dispatchId, ack 시 사용)
announcement_iduuid FK → tts_announcement송출된 안내방송
hq_iduuid FK → hq송출 주체 본사(스코프 검증)
store_iduuid FK → store수신 매장(매장당 1 row 로 fan-out)
statusvarcharDispatchStatus(SCHEDULED/PENDING/PLAYED/CANCELED, SPEC #077 + #078 4종 확장). 즉시 송출은 PENDING 생성. 예약 송출(#078)은 SCHEDULED 생성 → 디스패처가 도래 시 PENDING 전이 → ack(점장) → PLAYED · 본사 취소 → CANCELED (SCHEDULED 도 CANCELED 직행 가능)
created_attimestamp송출 row 생성 시각(점장 pending 목록 정렬 = created_at ASC). 예약 송출이면 이 값이 등록 시각이고, 실 송출 시각은 scheduled_at
played_attimestamp nullableack(재생 완료) 시각. SCHEDULED/PENDING/CANCELED 동안 null
scheduled_attimestamp nullable(SPEC #078, V29 — 본사 송출 · SPEC #083 점장 송출 공유) 예약 송출 시각. null=즉시 송출(기존 row 호환 — backfill 없음), non-null=예약 송출(SCHEDULED 적재 후 디스패처가 도래 시 PENDING 전이). 본사 dispatchHqTtsAnnouncement 와 점장 sendStoreBroadcast(SPEC #083) 모두 같은 컬럼에 적재HqDispatchScheduler 의 매분 cron 이 본사/점장 구분 없이 모든 status='SCHEDULED' AND scheduled_at <= now 행을 PENDING 으로 전이시킨다(코드 변경 0). partial index idx_announcement_dispatch_scheduled(status, scheduled_at) WHERE status='SCHEDULED' 로 디스패처 cron 쿼리 가속
play_attimestamp nullable(SPEC #178, V61) 절대 재생 시각 — 매장의 전 기기가 이 시각에 동시에 재생을 시작한다(각 기기가 큐 응답 serverNowIso 로 서버-클라 오프셋을 보정 + 목표 시각 전 오디오 프리페치, 기대 오차 수십 ms). 종전엔 각 기기가 20초 폴링으로 독립 수신해 최대 20초까지 어긋났다. 채우는 경로(V62 교정 후): 본사 단건 즉시 = 발행 + 2초(IMMEDIATE_PLAY_LEAD — 전 기기가 통지받고 준비할 여유) · 본사 단건 예약 = scheduled_at(row 생성 시) · 본사 반복 예약 전개(OccurrenceMaterializer) = 해당 회차 시각 · 점장 예약(StoreBroadcastService) = scheduledAt. 최종 안전망은 SCHEDULED→PENDING 전이 UPDATE 의 play_at = COALESCE(play_at, scheduled_at) — 모든 예약이 반드시 지나는 단일 지점이라 생성 경로가 늘어도 자동으로 시각을 갖는다(경로별 세팅을 빠뜨려 반복 예약·점장 예약만 NULL 이던 문제를 V62 가 백필+코드로 닫았다). 점장 즉시 송출은 NULL — 대상이 target_device_id 로 1대에 고정돼 시각을 맞출 상대가 없다. NULL 이면 받는 즉시 재생(구버전 row 포함). 별도 인덱스 없음 — 조회는 항상 store_id·status 기준이고 play_at 은 payload 로만 쓰인다
target_device_iduuid nullable, FK→store_device(SPEC #178, V63) 이 송출을 받을 대상 기기. NULL=매장 전 기기(본사 즉시·본사 예약·점장 예약·반복 전개·구버전 row 전부) · non-NULL=그 기기 1대 전용(점장 즉시방송 — 누른 PC). 기기 인지 pending 조회의 두 가지 모두 (target_device_id IS NULL OR = :deviceId) 로 거른다. deviceId 를 안 보내는 구버전 클라이언트는 IS NULL 인 것만 보므로 하위호환 유지. partial index idx_announcement_dispatch_target_device (WHERE target_device_id IS NOT NULL — 지정 방송은 소수)
signal_sourcevarchar(16) nullable(SPEC #180, V68) 기기가 이 송출을 알게 된 채널 — SSE(실시간 통지 스트림 = 가속 채널) / POLLING(주기 폴링 = 안전망). ack 옵션 필드로 클라이언트가 보고한다. NULL = 미보고(구버전·계측 이전 row) — POLLING 으로 간주하지 않는다
signal_received_attimestamptz nullable(SPEC #180, V68) 기기가 신호를 받은 시각(클라이언트 보고 · 큐 응답 serverNowIso 앵커로 서버 시각 보정). NULL = 미보고
playback_started_attimestamptz nullable(SPEC #180, V68) 기기가 실제로 재생을 시작한 시각(오버레이 playing · 서버 시각 보정). play_at(지시한 시각) 대비 편차 실측 = 전 기기 동시 재생 품질 확인. NULL = 미보고
signal_latency_msinteger nullable(SPEC #180, V68) 신호 지연 = signal_received_at − COALESCE(scheduled_at, created_at). 서버가 ack 수신 시점에 확정 계산한다(클라이언트가 보내지 않는다). 클라 시각이 신뢰 범위(30초 skew·1시간 상한)를 벗어나면 NULL(원값 signal_received_at 은 보존). 주간 p95 집계용 partial index idx_announcement_dispatch_signal_latency
audit_iduuid FK → hq_audit_log nullable(#071, V28) 이 송출 row 를 만든 audit 행위자 행과의 1:1 링크 — 같은 트랜잭션 안에서 audit INSERT 후 set. ON DELETE SET NULL. V28 이전 row 는 백필 안 함(null). 이력 다이얼로그 “행위자” 컬럼이 이 FK 를 LEFT JOIN 해 actorEmail·actorRole·impersonatedByEmail 노출
  • fan-out: target=ALL=산하 매장 전체, STORES=지정 매장. 매장 0건이면 dispatchedCount=0(에러 아님).
  • 중복 송출 허용: 같은 안내방송을 여러 번 송출하면 매장당 PENDING row 가 누적된다(unique 제약 없음 — 의도). 점장은 created_at ASC 로 하나씩 재생·ack.
  • ack 응답 = 404 계약: ack 은 원자 조건부 UPDATE(WHERE status='PENDING')다. 첫 ack(PENDING)만 204, 중복 ack·이미 PLAYED·타 매장·미존재 dispatchId 는 모두 404 DISPATCH_NOT_FOUND(상태·존재 은닉). 효과는 멱등(상태 불변)이나 HTTP 응답은 404 — “204 멱등”이 아니다. 점장 player 는 404 도 “이미 소비됨”으로 보고 정상 음악 복귀하며, audio 에러 시에도 ack 해 무한 멈춤을 막는다.
  • 기기별 ack (SPEC #178, V60): ack 요청에 deviceId 가 실리면 결과가 자식 테이블 dispatch_device_ack 에 기기별로 남고, 한 대라도 PLAYED 면 dispatch 를 PLAYED 로 종착시킨다(이미 다른 기기가 종착시켜 affected=0 이어도 404 를 주지 않는다 — 그 기기의 재생은 실제로 성공했다). 실패 경로는 이번 라운드의 온라인 기기 전부가 FAILED/SKIPPED 를 보고했을 때만 탄다(모수는 등록 기기 수가 아니라 온라인 기기 수). 종착 사유는 마지막 보고자가 아니라 실패 우선으로 고정한다 — countFailed > 0 이면 PLAYBACK_FAILED(3대 FAILED + 마지막 1대 SKIPPED 를 SKIPPED 로 남기면 본사 리포트의 원인 진단이 뒤집힌다).
  • 기기 미식별 폴백 ack 도 정족수를 존중한다 (SPEC #178 통합 검토): 상한 초과·회수로 deviceId 없이 ack 하는 PC 가 매장 전체의 결론을 혼자 뒤집지 않게, 다른 기기가 이미 PLAYED 를 보고했으면 (countPlayed > 0 + 소유 확인) PLAYED 는 affected=0 이어도 204(404 아님 — 기기 경로와 같은 의미), FAILED/SKIPPED 는 실패 카운터를 올리거나 MISSED 로 종결하지 않는다(이미 매장에 들린 방송). 소유 확인을 함께 걸어 미존재·타 매장 dispatch 는 그대로 404 은닉이다. deviceId 가 없고 아무도 재생하지 않았으면 위 매장 단위 404 계약 그대로다. 위 매장 기기 참조.
  • 신호 계측 (SPEC #180, V68): ack 요청의 옵션 3필드(signalSource·receivedAt·startedAt)를 ack 커밋 이후 별도 트랜잭션에서 기록하고 signal_latency_ms 를 확정 계산한다. 같은 트랜잭션에 두면 계측 실패가 ack 전체를 롤백시켜 점장이 재생을 끝냈는데 dispatch 가 PENDING 으로 남아 중복 재생된다(PostgreSQL 은 실패 statement 이후 트랜잭션을 aborted 로 만들어 try/catch 로도 못 막는다). 계측이 재생 보고를 실패시키지 않는다는 것이 이 컬럼들의 최상위 불변식이다 — 형식 오류·알 수 없는 값은 400 이 아니라 미보고(NULL)로 흡수한다.
  • audit 매핑 (#071, V28): audit_id FK 가 hq_audit_log.id 를 가리킨다. 본사 송출 service 가 같은 트랜잭션 안에서 audit row INSERT 후 그 id 를 dispatch row 에 set 한다(원자성, #067 패턴 미러). 이력 다이얼로그가 이 컬럼으로 audit row 와 LEFT JOIN 해 actor 정보를 노출. 인덱스 idx_announcement_dispatch_audit_id(audit_id) — 후속 분석/조인 가속용. V28 이전 row 는 null (백필 안 함).
  • 화면·재생 흐름은 Store Player · HQ TTS 안내방송 송출 · DispatchStatus enum 참조.

HqAuditLog (#067) — 본사 송출 actor 감사

본사(HQ_MANAGER)의 안내방송 송출 액션을 actor·시각·대상 스냅샷으로 append-only 기록하는 백본 (hq_audit_log, Flyway V27). dispatch row(announcement_dispatch, V23)는 PLAYED 로 UPDATE 되는 mutable row 라 actor 추적과 짝이 안 맞아 별도 테이블로 분리. operator_audit_log 확장 대신 신규 테이블 — actor 컬럼·enum·운영사 audit UI 가 OPERATOR-only 의미로 굳어 의미 오염·권한 문제 회피.

컬럼타입비고
iduuid PK감사 행 id
occurred_attimestamptz NOT NULL발생 시각 (정렬 키)
created_attimestamptz NOT NULLINSERT 시각 (Auditing)
hq_iduuid FK → hq테넌트 격리(쿼리에 WHERE hq_id 강제)
actor_account_iduuid FK → operator_account행위자 본사 매니저 계정
actor_emailvarchar(255) NOT NULL행위자 이메일 스냅샷(계정 후속 삭제 대비)
actor_rolevarchar(32) NOT NULLHQ_MANAGER (직접) / OPERATOR_IMPERSONATING (운영자 위장 중)
impersonated_by_operator_iduuid FK → operator_account nullableOPERATOR_IMPERSONATING 일 때만 — 원본 운영자
impersonated_by_emailvarchar(255) nullable원본 운영자 이메일 스냅샷
actionvarchar(48) NOT NULLHQ_ANNOUNCEMENT_DISPATCHED (현재 1종)
target_typevarchar(32) NOT NULLTTS_ANNOUNCEMENT
target_iduuid nullable안내방송 id
target_labelvarchar(255) nullable대상 라벨 스냅샷(안내방송 제목)
detailvarchar(1024) nullable액션 상세(target=ALL·count=N·storeIds=[...])
  • CHECK 제약: (actor_role='OPERATOR_IMPERSONATING') = (impersonated_by_operator_id IS NOT NULL) — impersonate 두 컬럼의 정합성을 DB 차원에서 강제(V9 패턴 미러).
  • 인덱스:
    • idx_hq_audit_hq_id_occurred(hq_id, occurred_at DESC, id DESC) — hqId leading + 결정적 정렬(V13 패턴).
    • idx_hq_audit_actor_account_id(actor_account_id) — actor 필터.
    • idx_hq_audit_action(action) · idx_hq_audit_target_type(target_type) · idx_hq_audit_target_id(target_id) — 액션·대상 필터(announcement_id → audit→이력 join 가능성).
  • 기록 hook: HqAnnouncementDispatchService.dispatch 끝, 같은 트랜잭션 내 HqAuditService.record 1줄 (propagation REQUIRED 자연 합치). dispatch row INSERT 와 audit INSERT 가 한 트랜잭션 — audit 실패 시 전체 롤백(원자성, #026 패턴). dispatch row 0건이어도 dispatch endpoint 호출 자체는 audit(count=0 기록).
  • impersonation actor 해석: actor_account_id = principal.accountId (HQ_MANAGER 계정), actor_email = OperatorAccountRepository.findById(principal.accountId).email. principal.impersonatedBy != null 이면 actor_role=OPERATOR_IMPERSONATING + impersonated_by_operator_id/email 동시 기록. 둘 다 lookup 실패 시 IllegalStateException → 액션 롤백(audit actor 미상으로 진행 거부, #026 정합성 원칙).
  • 보안 불변식: search 쿼리에 WHERE hq_id = :hqId 강제(타 본사 격리). impersonate 중인 운영자도 본사 토큰이라 같은 hqId 만 조회 — 자기가 한 액션 노출(운영사 책임 추적 자연 폐쇄).
  • 화면·필터·페이지네이션은 HQ Mode 감사 참조.
  • enum 도메인: HqAuditAction · HqAuditActorRole · HqAuditTargetType.

StoreAuditLog (#114) — 점장 액션 감사

점장(STORE_MANAGER)의 핵심 액션(ticket 생성·댓글·프로필 편집·비밀번호 변경·예약 송출 취소)을 actor·시각·대상 스냅샷으로 append-only 기록하는 백본(store_audit_log, Flyway V36). HqAuditLog(V27)의 1:1 미러 — 테넌트 스코프 키만 hq_idstore_id 로 바뀌고 actor 컬럼·임퍼소네이션 정합성·인덱스 전략이 동일하다. operator_audit_log(운영자 액션)·hq_audit_log(본사 액션) 와 의미가 분리된 별도 테이블 — actor·테넌트 스코프(store_id)·UI 권한 모델이 다르다.

컬럼타입비고
iduuid PK감사 행 id
occurred_attimestamptz NOT NULL발생 시각 (정렬 키)
created_attimestamptz NOT NULLINSERT 시각 (Auditing)
store_iduuid FK → store테넌트 격리(조회 쿼리에 WHERE store_id 강제 — 후속 view)
actor_account_iduuid FK → operator_account행위자 점장 계정(점장·운영자 모두 operator_account 테이블)
actor_emailvarchar(255) NOT NULL행위자 이메일 스냅샷(계정 후속 변경 대비)
actor_rolevarchar(32) NOT NULLSTORE_MANAGER (직접) / OPERATOR_IMPERSONATING (운영자 점장 모드 위장 중)
impersonated_by_operator_iduuid FK → operator_account nullableOPERATOR_IMPERSONATING 일 때만 — 원본 운영자
impersonated_by_emailvarchar(255) nullable원본 운영자 이메일 스냅샷
actionvarchar(48) NOT NULLStoreAuditAction 5종 (아래)
target_typevarchar(32) NOT NULLSUPPORT_TICKET · STORE_MANAGER(self) · DISPATCH
target_iduuid nullableticket id · 점장 계정 id · dispatch id
target_labelvarchar(255) nullable대상 라벨 스냅샷(ticket 제목 등)
detailvarchar(1024) nullable액션 상세 스냅샷(액션별 — 아래)
  • action 5종 + 기록 hook (모두 액션 성공 직후 같은 트랜잭션 안에서 StoreAuditService.record 1줄, audit INSERT 실패 시 본 작업도 함께 롤백 — 원자성, V27 패턴):
    • STORE_TICKET_CREATED (#112 audit tail 마감) — StoreTicketService.createStoreTicket. target=SUPPORT_TICKET·target_id=ticket.id·target_label=ticket.title. detail=title=...·category=NAME·bodyLength=N(본문 자체는 audit 에 두지 않음).
    • STORE_TICKET_COMMENT_ADDED (#112) — StoreTicketService.addStoreTicketComment. target=SUPPORT_TICKET·target_id=ticket.id(댓글 id 아님 — audit 는 ticket scope)·target_label=ticket.title. detail=bodyLength=N.
    • STORE_PROFILE_UPDATED (#087 F4) — StoreMeService.updateMe. target=STORE_MANAGER·target_id=점장 계정 id·target_label=변경 후 name. detail=changedFields=name·name:before->after. 실제 변경 0(멱등 no-op)이면 audit row 미생성(HqMe 관례 미러).
    • STORE_PASSWORD_CHANGED (#100 F2) — 공용 AuthService.changePassword 의 role=STORE_MANAGER 분기. target=STORE_MANAGER·target_id=점장 계정 id. detail 없음(비밀번호 자체·길이는 audit 에 두지 않음 — 보안). change-password 정책상 임퍼소네이션 차단되므로 actor 는 항상 본인.
    • STORE_DISPATCH_CANCELED (#092 F2) — StoreBroadcastService 예약 송출 취소. target=DISPATCH·target_id=dispatch.id. detail 없음(원자 UPDATE 후 affected=1 확정 시에만 기록 — 사전 조회 없이 race window 0 유지).
  • CHECK 제약: (actor_role='OPERATOR_IMPERSONATING') = (impersonated_by_operator_id IS NOT NULL) — impersonate 두 컬럼의 정합성을 DB 차원에서 강제(V27 미러).
  • append-only: UPDATE/soft-delete 경로가 없다 — 기록 hook 의 단순 INSERT 만 발생(불변성을 schema 로 강제해 updated_at·deleted_at 컬럼을 두지 않음).
  • 인덱스(V27 미러):
    • idx_store_audit_store_id_occurred(store_id, occurred_at DESC, id DESC) — store_id leading + 결정적 정렬.
    • idx_store_audit_actor_account_id(actor_account_id) · idx_store_audit_action(action) · idx_store_audit_target_type(target_type) · idx_store_audit_target_id(target_id).
  • actor 임퍼소네이션 해석(SPEC #114 D2 — HqAudit 분기 동일): actor_account_id = principal.accountId (점장 계정), actor_emailOperatorAccountRepository.findById 스냅샷. principal.impersonatedBy != null 이면 actor_role=OPERATOR_IMPERSONATING + impersonated_by_operator_id/email 동시 기록(운영자가 점장 모드로 한 액션도 정확히 추적). actor·impersonator 어느 쪽이든 lookup 실패 시 IllegalStateException → 액션 롤백(actor 미상으로 기록 거부, #026 정합성 원칙).
  • v1 = 기록만 (reader 없음, D5): 점장 본인은 자기 audit 을 볼 필요가 없다. 운영자/본사가 store audit 을 조회하는 view 는 후속(운영자 audit 화면 확장 — 별 SPEC). 본 슬라이스는 BE 기록 인프라 + hook 까지(HqAudit 도 기록 먼저·view 나중 패턴). public endpoint 0, API 무변경.
  • 액션·기록 경로 서술은 Store 감사 (store_audit_log) 참조.
  • enum 도메인: StoreAuditAction · StoreAuditActorRole · StoreAuditTargetType.

BroadcastTemplate (자주쓰는방송 슬라이스 — 점장 자주 쓰는 방송 템플릿)

점장(STORE_MANAGER)이 자주 쓰는 안내(주차·시음·영업 종료 등)를 이름·텍스트·목소리로 저장해두고 재사용하는 매장 스코프 테이블(broadcast_template, Flyway V25). store 와 1:N(store_id FK).

컬럼타입비고
iduuid PK템플릿 id
store_iduuid FK → store소유 매장(토큰 주체에서 도출, 타 매장 비노출)
namevarchar(60)표시 이름 (1~60자)
textvarchar(200)방송할 텍스트 (1~200자, 즉시방송 text 와 동일 제약)
voicevarcharTtsVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA)
created_at/updated_attimestampAuditing. 목록 정렬 = updated_at DESC
deleted_attimestamp nullablesoft-delete. 활성 = deleted_at IS NULL
  • 매장당 활성 ≤20: 생성 시 활성 count 를 검증해 상한(20) 도달 시 409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED. FE 는 total>=20 이면 [+ 새 템플릿]을 사전 비활성하고 안내 배너를 띄운다.
  • 소유 은닉: 수정/삭제 대상이 본인 매장 활성 템플릿이 아니면(미존재·삭제·재삭제·타 매장) 404 BROADCAST_TEMPLATE_NOT_FOUND(존재·소유 은닉).
  • 로드 전용 재사용: 템플릿은 합성/송출하지 않고 즉시방송 TTS 탭 입력(text·voice)을 채우는 로드까지만 쓰인다 — 미리듣기·송출은 즉시방송 게이트(previewStoreBroadcastsendStoreBroadcast)가 맡는다.
  • 화면·CRUD 흐름은 점장 즉시방송 — 자주 쓰는 방송 탭 · BroadcastTemplate DTOs 참조.

CommercialSong (#093) — 본사 CM송(광고/공지 음원)

본사(HQ_MANAGER)가 직접 등록·관리하는 광고/공지 음원(commercial_song, Flyway V31). TTS 안내방송 (tts_announcement, V21)이 “합성으로 매장에 송출” 하는 콘텐츠라면, CM송은 “사전 업로드 음원을 점장 player 가 N곡마다 1회 자동 재생” 하는 다른 도메인이다(#093 슬라이스는 백본 + 본사 관리 UI, 점장 player 사이클 재생은 SPEC #094·#095·#103·#104 에서 완료 — 재생 모델은 #141 오버레이 동시재생). hq 와 1:N(hq_id FK).

컬럼타입비고
iduuid PK앱에서 시간순 UUID 선생성(음원 #041·TTS 안내방송 V21 패턴 — F1 후속에서 blob key=PK 정책 검토)
hq_iduuid NOT NULL소유 본사. 조회/수정/삭제 시 토큰 hqId 와 일치 검증(verifyHqScope, 타 본사 404 은닉)
titlevarchar(200) NOT NULL표시명(1~200)
audio_urltext NOT NULL음원 URL. SPEC #174: 서버가 MP3 파일 업로드를 검증(magic byte·20MB·비어있지 않음, ID3 rewrite 미경유 as-is)한 뒤 Azure blob({prefix}/commercials/{id}.mp3)에 저장하고 이 컬럼을 채운다 — 이제 클라이언트 URL 이 아니라 서버 blob 업로드 결과다(등록 createHqCommercial multipart·교체 PUT /{id}/file 둘 다 같은 key 덮어쓰기). 마이그레이션 없음(컬럼 재사용)
duration_secondsinteger NOT NULL오디오 길이(초) — 1..3600(1시간 이하)
is_activeboolean NOT NULL DEFAULT TRUE점장 player 사이클 재생 대상 토글(#094 라운드로빈 후보 필터). 생성 시 true 기본
created_at·updated_attimestamptz NOT NULLUTC
deleted_attimestamptz nullablesoft-delete. 삭제 시 채움, blob 유지(복구 가능 — F3 audit 누적에서 archive 정책 검토)
  • partial index idx_commercial_song_hq_active(hq_id, is_active) WHERE deleted_at IS NULL — 본사 격리 쿼리(WHERE hq_id = :hqId AND deleted_at IS NULL)와 F1 후속의 활성 row 집계 가속(전체 row 가 아니라 활성만 인덱스 적재).
  • 생성·수정·삭제 = HQ_MANAGER-only/api/v1/hq/commercials/** prefix 매처 → hasRole("HQ_MANAGER") 1차 경계 + service verifyHqScope claim↔DB 재검증(미인증 401 · 소속 불일치 403 PRINCIPAL_SCOPE_MISMATCH).
  • 격리 불변식 — 모든 endpoint 가 WHERE hq_id = :hqId AND deleted_at IS NULL 강제(BE D3). 타 본사 row·삭제 row 는 404 COMMERCIAL_SONG_NOT_FOUND 로 존재 은닉.
  • 수정 부분 갱신(PATCH)title?: string|null·isActive?: boolean|null (null=미변경, BE D2). 두 필드 모두 null = no-op(현재 상태 그대로 200). 오디오(audio_url·duration_seconds) 교체는 별도 endpoint PUT /{id}/file(#174, multipart) — 같은 blob key 덮어쓰기로 내용·길이 갱신(PATCH 는 메타만). 등록·교체는 audit HQ_COMMERCIAL_CREATED·HQ_COMMERCIAL_FILE_REPLACED 로 남는다.
  • 삭제 = soft-deleteUPDATE ... WHERE id AND hq_id AND deleted_at IS NULL(affected=0 → 404 COMMERCIAL_SONG_NOT_FOUND). blob 유지·hard purge 는 후속.
  • 화면·CRUD 흐름은 HQ Mode CM송 관리 · HqCommercial DTOs 참조.

Auditing

  • BaseEntity.createdBy/updatedByAuditorAware<UUID> → 현재 OperatorAccount.id
  • BaseEntity.createdAt/updatedAt — Spring Data JPA @CreatedDate/@LastModifiedDate (UTC)
  • 인증 없는 컨텍스트 (Flyway 시드) — createdBy = NULL

Roadmap

  • HqStatus 정지/복구 전이 + 관리 UI ✅ (SPEC #018) · 자동 전환·사유 저장은 후속
  • ContractPlan · BillingKey · Invoice · PaymentResult · TaxInvoice (Billing v1)
  • Music (음원 파일 업로드·카탈로그 조회·소프트삭제) ✅ (SPEC #041/#042 — music 테이블 V15, 조회·삭제는 스키마 무변경)
  • Library · LibraryMusic (라이브러리 큐레이팅 — 음원→라이브러리 2계층) ✅ (SPEC #053 — library·library_music 테이블 V16) · 음원 타입 enforcement(F1)·hard-purge 배치는 후속
  • Playlist · PlaylistLibrary (플레이리스트 — 라이브러리를 담음, 2계층 최상위) ✅ (SPEC #054 — playlist·playlist_library 테이블 V17) · 매장 적용(StoreActivePlaylist) ✅ (#055) · 본사 조회 ✅ (#057 — listHqPlaylists·getHqPlaylist, 스키마 무변경) · 본사 기본 PL(is_default) ✅ (#058 — V19 컬럼 + partial unique index, 점장 큐 fallback) · status 5종 파생은 후속(F3)
  • TenantAuditLog (감사 로그) — TRD §6

References

  • SPEC #001 · #002 · #003 · #004 · #005 · #008 · #011
  • linkmusic-msa-space-was/src/main/resources/db/migration/
  • TRD 30-trd-architecture.md §4-1