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 | 파일 | 내용 |
|---|---|---|
| V1 | V1__init.sql | 초기 (SPEC #001 bootstrap). 빈 schema 확인 + helper |
| V2 | V2__add_hq_store.sql | hq, store CREATE + 가상 본사 INSERT + partial unique |
| V3 | V3__auth_and_audit.sql | operator_account · refresh_token CREATE + hq·store 에 created_by·updated_by ALTER + 첫 OPERATOR (dev@chilloen.com) 시드 |
| V4 | V4__terms_and_consent.sql | terms_document·privacy_policy·4개 consent table CREATE + partial unique |
| V5 | V5__impersonation_token.sql | impersonation_token CREATE (SPEC #005) |
| V6 | V6__store_manager_optional.sql | (#011) Store 가입 시 manager 정보 nullable 강화 |
| V7 | V7__prod_operator_seed.sql | (#008) prod-only seed — prod@chilloen.com |
| … | (#018·#024·#027·#032·#033·#037·#039 등 후속 마이그레이션) | HqStatus 전이·정지 사유·CS 티켓·운영자 계정·약관 effectiveAt·매장 전이/폐점 등 |
| V15 | V15__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 모두 기존 자원) |
| V16 | V16__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 |
| V17 | V17__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 |
| V18 | V18__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 |
| V19 | V19__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 |
| V20 | V20__add_music_source.sql | (#059) music.music_source 컬럼 ADD — 음원 타입(AI/TRUST). 업로드 시 지정·불변. 라이브러리 library_type 과 동일 도메인이며 library_music 할당 시 일치를 강제(불일치 400 LIBRARY_TYPE_MISMATCH). 기존 행 backfill 정책은 BE 마이그레이션 결정. 상세는 Music · MusicSource enum |
| V21 | V21__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 |
| V23 | V23__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 |
| V24 | V24__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 |
| V25 | V25__create_broadcast_template.sql | (자주쓰는방송 슬라이스) broadcast_template 테이블 CREATE — 점장 자주 쓰는 방송 템플릿. id(uuid PK) · store_id FK→store · name(1text(1voice(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 |
| V26 | V26__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 안내방송 송출 이력 다이얼로그. |
| V27 | V27__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. |
| V28 | V28__announcement_dispatch_audit_id.sql | (#071) announcement_dispatch.audit_id 컬럼 ADD — uuid 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. |
| V29 | V29__announcement_dispatch_scheduled_at.sql | (#078) announcement_dispatch.scheduled_at 컬럼 ADD — timestamp NULL. null=즉시 송출(기존 row 호환 — backfill 없음), non-null=예약 송출 시각(SCHEDULED 적재 후 백그라운드 디스패처가 도래 시 PENDING 전이). 같은 SPEC 에서 DispatchStatus 에 SCHEDULED 값 추가(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. |
| V30 | V30__tts_announcement_is_emergency.sql | (#082) tts_announcement.is_emergency 컬럼 ADD — BOOLEAN 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. |
| V31 | V31__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. |
| V32 | V32__hq_commercial_cycle_songs.sql | (#095) hq.commercial_cycle_songs 컬럼 ADD — INT 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. |
| V33 | V33__store_commercial_cycle_songs.sql | (#103, #094 F2 마감) store.commercial_cycle_songs 컬럼 ADD — INT 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. |
| V34 | V34__store_last_commercial_song_id.sql | (#104, #094 F3 마감) store.last_commercial_song_id 컬럼 ADD — UUID NULL + FOREIGN KEY ... REFERENCES commercial_song(id) ON DELETE SET NULL. 매장별 CM 라운드로빈 커서. null = 라운드로빈 시작 전(신규 매장 또는 마지막 CM 이 hard-delete 됐을 때). getStoreNextCommercial 가 WHERE 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 default — hq 에 duck_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 override — store 에 duck_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(UpdateStoreDuckingRequest — per-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==TRUST)·started_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 은 지금 넣으면 오히려 과거 시각이라 대상 아님). 코드도 세 곳을 함께 고쳤다: transitionScheduledIdsToPending 의 play_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 index — idx_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배 가산(신탁 신고·월 청구 근거라 정확도가 곧 계약 리스크). insertIfAbsentWithoutDevice 에 NOT 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_id → store_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개 ADD — signal_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 컬럼을 그대로 재사용한다.
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 앱에서 시간순 UUID 선생성(이 엔티티만 @UuidGenerator 자동부여 미사용, id 선할당). key = {prefix}/{id}.mp3 로 blob 파일명 = PK (A안 — 식별자 통일). blob↔row 1:1, 매핑 컬럼 불필요 |
title | varchar | TIT2 재기록 값(클라 추출 제공) |
audio_url | varchar | 저장된 음원 url — 어댑터별 규약 문자열(Local / Azure Blob) |
duration_seconds | int | 곡 길이(초). 클라이언트 추출. DB 컬럼만 — ID3 미기록 |
music_source | varchar | (#059) 음원 타입 AI/TRUST. 업로드 시 지정·불변. 라이브러리 library_type 과 일치해야 할당 가능 |
created_at·updated_at | timestamp | BaseEntity (UTC) |
created_by·updated_by | uuid | AuditorAware — 현재 OperatorAccount.id |
deleted_at | timestamp | soft-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_activepartial index 가 처음 효력을 갖는다. - 파일 교체 = 같은 key 덮어쓰기 —
key={id}.mp3고정이라 교체 시 동일 blob 을 덮어쓴다(파일 이력 없음). 이력은OperatorAuditLogrow(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
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 옵션 id |
type | varchar | MusicTagOptionType — GENRE(장르)·MOOD(무드). 생성 후 불변 |
value | varchar(50) | 옵션 값(태그명). @NotBlank @Size(1..50) |
sort_order | int | 타입 내 표시 순서(오름차순). NOT NULL DEFAULT 0(미지정 생성 시 0) |
active | boolean | 활성 여부. NOT NULL DEFAULT true. false = soft-deleted/비활성 |
created_at·updated_at | timestamp | ISO-8601 |
- unique(
type,value) — 같은 타입 내 동일 value 중복 차단(409MUSIC_TAG_OPTION_DUPLICATE근거). 목록 조회 정렬sort_order ASC → value ASC(결정적).
- soft delete =
active=false—deleteMusicTagOption은active만false로 내린다(row 유지). 이미 비활성이어도 멱등 204. 음원이 그 value 를 참조 중이어도 안전(값 자체는 보존). 목록은 비활성 옵션도 함께 노출(운영자 관리 화면에서 재활성/구분 가능 —active=false행은 UI 에서 흐림 + “비활성” 배지). - type 불변 — 수정(
PATCH)은value·sort_order·active만 갱신,type은 변경 불가. - 페이지네이션 없음 — 타입별 옵션 수가 소규모라 전체 목록을 한 번에 반환(
{ items }). - 운영사 UI(
/settings/music-options2섹션 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
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | @UuidGenerator 자동부여 |
name | varchar | 라이브러리 이름 (1..255) |
library_type | varchar | AI(AI 생성)·TRUST(신탁). 생성 시 고정(이후 불변). 단일 타입 묶음(혼합 불가) |
created_at·updated_at | timestamp | BaseEntity (UTC) |
created_by·updated_by | uuid | AuditorAware — 현재 OperatorAccount.id |
deleted_at | timestamp | soft-delete (nullable). 삭제 시 채움 — 담긴 음원 할당은 유지(복구 가능) |
idx_library_created_at (created_at DESC)·idx_library_active (id) WHERE deleted_at IS NULL.
library_music (음원 할당 M:N)
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | |
library_id | uuid FK → library | |
music_id | uuid FK → music | |
created_at | timestamp | 할당 시각 — 라이브러리 곡 목록 정렬 키(DESC) |
- unique(
library_id,music_id) — 중복 할당 방지(추가 멱등의 근거).idx_library_music_library (library_id).
- 할당 멱등 —
addLibraryMusic은 unique 제약으로 중복 추가를 무시한다(이미 담긴 음원은 skip).removeLibraryMusic도 멱등(담겨 있지 않아도 성공). 음원 제거 시library_musicrow 만 삭제하고 음원(music) 자체는 유지한다. - 라이브러리 소프트삭제 —
library.deleted_at만 채우고library_music할당은 유지(복구 가능). 목록·상세는 명시적deleted_at IS NULL로 활성만 노출(음원 #042 패턴). - 음원 타입 enforcement (#059) —
music.music_source(AI/TRUST) ↔library.library_type일치를 강제한다.addLibraryMusic시 불일치 음원이 있으면 전체 reject → 400LIBRARY_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
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | @UuidGenerator 자동부여 |
hq_id | uuid FK → hq | 소유 본사. 생성 시 고정(이후 불변). 운영사가 본사별 PL 관리 |
name | varchar | 플레이리스트 이름 (1..255) |
is_default | boolean NOT NULL DEFAULT false | (#058) 본사 기본 PL 여부. 본사당 최대 1개(partial unique index). 점장 큐 fallback 기준 |
created_at·updated_at | timestamp | BaseEntity (UTC) |
created_by·updated_by | uuid | AuditorAware — 현재 OperatorAccount.id |
deleted_at | timestamp | soft-delete (nullable). 삭제 시 채움 |
idx_playlist_hq (hq_id)·idx_playlist_active (id) WHERE deleted_at IS NULL· (#058) partial uniqueuq_playlist_default (hq_id) WHERE is_default AND deleted_at IS NULL(본사당 기본 PL 1개 불변식 — soft-delete 시 자동 무효).
playlist_library (라이브러리 담기 M:N + 순서)
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | |
playlist_id | uuid FK → playlist | |
library_id | uuid FK → library | |
position | int | 담긴 순서 — 라이브러리 목록 정렬 키(ASC). reorder 로 일괄 갱신 |
- unique(
playlist_id,library_id) — 중복 담기 방지(담기 멱등의 근거).idx_playlist_library_playlist (playlist_id).
- 담기 멱등 —
addPlaylistLibrary는 unique 제약으로 중복 담기를 무시하고(이미 담긴 라이브러리 skip) 끝에 append 한다.removePlaylistLibrary도 멱등(담겨 있지 않아도 성공). 제거 시playlist_libraryrow 만 삭제하고 라이브러리(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_id | uuid FK → playlist, nullable | 매장의 활성 PL. NULL = 미적용. 단일 활성(교체 = 덮어쓰기) |
active_playlist_applied_at | timestamptz, nullable | V52(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)와 같아야 한다. 불일치 시 409STORE_PLAYLIST_HQ_MISMATCH(두 리소스 모두 존재하나 조합 금지). FE 는 후보 PL 을 매장 hqId 로 한정해 사전 차단. - 적용 = 원자적 조건부 UPDATE — 사전 조회(store·playlist 존재 + hqId 일치 + PL 활성) 후
active_playlist_id갱신. soft-deleted PL 적용 시 404PLAYLIST_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)
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | |
playlist_id | uuid FK → playlist | |
library_id | uuid FK → library | 이 구간에 재생할 라이브러리(그 PL 의 멤버) |
start_minute | int | 구간 시작(하루 중 분, 0~1410, 30분 배수) |
end_minute | int | 구간 종료(30~1440, 30분 배수, start<end, 반열림) |
created_at·updated_at | timestamptz | |
created_by·updated_by | uuid FK → operator_account, nullable | BaseEntity auditor(본사 기본은 OPERATOR 가 생성) |
deleted_at | timestamptz, nullable | soft-delete |
(playlist_id, library_id)에 unique 없음 — 여러 행 허용(재즈 10–12시 + 18–20시). 멤버십은playlist_library가 소유하고 이 테이블은 “그 멤버를 언제 트는가”만 소유한다.ck_playlist_library_schedule_boundsCHECK — 30분 배수·0≤start<end≤1440(앱 검증 + DB 이중 안전).ex_playlist_library_schedule_no_overlapEXCLUDE 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)
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | |
store_id | uuid FK → store | override 소유 매장 |
playlist_id | uuid FK → playlist | override 대상 PL(매장 활성 PL) |
library_id | uuid FK → library | |
start_minute·end_minute | int | V53 과 동일 30분 배수·반열림 |
created_at·updated_at | timestamptz | |
created_by·updated_by | uuid FK → operator_account, nullable | 점장(STORE_MANAGER)도 단일 operator_account 에 role 판별자로 저장 → FK 정합 |
deleted_at | timestamptz, nullable | soft-delete |
- 복사 후 수정(D4) — 점장이 “커스텀 시작”하면 활성 PL 의 본사 시간표를 이 테이블로 통째 복사·시딩한
뒤 편집한다.
(store_id, playlist_id)행이 하나라도 있으면 그 PL 의 본사 시간표를 통째로 대체(부분 override 아님). 커스텀 해제 → 그(store_id, playlist_id)행 전부 삭제 → 본사 시간표로 복귀. ck_store_library_schedule_boundsCHECK — V53 과 동일.ex_store_library_schedule_no_overlapEXCLUDE 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/adminPL 상세 [시간표] 탭) · 본사 (HQ_MANAGER) 읽기 전용(apps/spacePL 상세 [시간표] 탭). 매장 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).
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 앱에서 시간순 UUID 선생성(music 패턴). key = {prefix}/tts/{id}.mp3 로 blob 파일명 = PK. blob↔row 1:1 |
hq_id | uuid FK → hq | 소유 본사. 조회/삭제 시 토큰 hqId 와 일치 검증(verifyHqScope, 타 본사 404 은닉) |
title | varchar(255) | 안내방송 제목 |
text | text | 합성에 쓴 원문(≤1000) |
voice | varchar | TtsVoice(SHEAN/WOOSUNG/CYRUS/AERAN/SEUNGA) |
audio_url | varchar | 합성 오디오 url(Azure public blob — 직접 재생) |
duration_seconds | int nullable | 오디오 길이(초). Typecast 가 줄 때만 채워짐 |
created_at·updated_at | timestamp | BaseEntity(UTC) |
created_by·updated_by | uuid | AuditorAware |
deleted_at | timestamp | soft-delete(nullable). 삭제 시 채움, blob 유지(복구 가능) |
source | varchar | (즉시방송 슬라이스, V24) TtsAnnouncementSource HQ/STORE_BROADCAST. 기존 행 backfill HQ. 점장 즉시방송 미리듣기가 STORE_BROADCAST draft 를 만든다 |
created_by_store_id | uuid FK → store nullable | (즉시방송 슬라이스, V24) STORE_BROADCAST 일 때만 채워짐(만든 매장). HQ source 는 NULL |
is_emergency | boolean 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_BROADCASTrow 는 같은 테이블을 쓰지만 본사 화면·계약에 절대 노출되지 않는다(점장 즉시방송은 본인 매장에만 송출). 즉시방송 preview/send 흐름은 점장 즉시방송 참조.
- 생성 = 합성 동기 완료 —
verifyHqScope→TypecastClient.synthesize→ blob 업로드 → row insert. insert/audit 실패 시 보상 삭제(orphan blob 방지). 외부 실패는 502TTS_SYNTHESIS_FAILED· 토큰 미설정 503TTS_TOKEN_NOT_CONFIGURED로 격리(5xx 금지). - 삭제 = soft-delete —
UPDATE ... WHERE id AND hq_id AND deleted_at IS NULL(affected=0 → 404TTS_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)
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | |
store_id | uuid, FK→store | |
hq_id | uuid, FK→hq | 테넌트 스코프(본사 조회 인덱스) |
music_id | uuid, FK→music | 재생한 곡 |
library_id | uuid nullable, FK→library | 재생 컨텍스트 라이브러리(선택) |
music_source | MusicSource(AI/TRUST) | 곡의 music.musicSource 스냅샷 |
is_trust | boolean | = (music_source==TRUST) — 신탁 신고 필터 |
started_at | timestamptz | 재생 시작 시각 |
played_ms | int | 실제 재생 밀리초(부분·스킵 포함, 0..86400000) |
created_at | timestamptz | |
device_id | uuid nullable, FK→store_device | (SPEC #178, V59) 보고한 기기. NULL = 구버전 클라이언트(기기 미식별) 보고 |
cache_hit | boolean 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 선행 + 기기 보고 후행)은
insertIfAbsentForDevice의NOT EXISTS … device_id IS NULL가드가 막았고, 반대 방향(기기 보고 후 그 기기가 회수되면 재전송 시 서버가 무효 id 를 null 로 접어 매장 단위 경로로 들어간다)은insertIfAbsentWithoutDevice의 대칭 가드(NOT EXISTS같은(store, music, started_at)의device_id IS NOT NULLrow)가 막는다.play_log2행 +playback_daily_rollup2배는 신탁 신고 근거이자 월 청구 근거라 정확도가 곧 계약 리스크다. 매장 전체가 아니라 같은 키만 보므로 다른 구버전 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).
- V65
- append-only — UPDATE/soft-delete 경로 없음. raw 는 N개월 보존 후 롤업만 유지(보존 정책 미결 M1).
store_playback_status (매장 현재 상태 1행·heartbeat upsert)
| 컬럼 | 타입 | 비고 |
|---|---|---|
store_id | uuid PK, FK→store | 매장당 정확히 1행 |
hq_id | uuid, FK→hq | 본사 대시보드 격리 조회 |
state | PlaybackState(PLAYING/PAUSED/SILENT/OFFLINE) | PLAYING/PAUSED/SILENT 는 점장 보고값 · OFFLINE 은 서버가 last_heartbeat_at staleness(초안 7분 초과)로 파생(저장값 아님) |
current_music_id | uuid nullable | 현재 재생 곡. 무음/일시정지엔 null |
last_heartbeat_at | timestamptz | 마지막 report 시각 — OFFLINE 파생·조회의 기준 |
updated_at | timestamptz |
- 매 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_id | uuid, PK 일부 | |
day_kst | date, PK 일부 | KST 기준 일자 |
hq_id | uuid, FK→hq | |
played_ms_total | bigint | 그날 총 재생 시간 |
played_ms_trust | bigint | 그중 신탁(TRUST) 재생 시간 |
track_count | int | 그날 재생 곡 수 |
track_count_trust | int | 그중 신탁 곡 수 |
updated_at | timestamptz |
- 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)
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 서버 발급. 이후 요청의 deviceId |
store_id | uuid FK → store | |
device_key | varchar(64) | 클라이언트 발급 UUID(브라우저 localStorage lm.device.key). 서버는 값을 만들지 않고 받기만 한다 — 같은 브라우저 프로필이면 재방문 시 같은 키가 와서 등록이 멱등해진다 |
label | varchar(50) | 점장이 붙이는 이름(“1층”·“카운터”). 등록 시 서버가 기기 N 으로 채움. CHECK length(btrim(label)) > 0 |
last_seen_at | timestamptz | 마지막 신호(등록·재생 보고 시 갱신) — 점장이 “안 쓰는 기기”를 판단하는 근거 |
created_at·updated_at·created_by·updated_by | BaseEntity auditing | |
deleted_at | timestamptz, nullable | soft-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 로 박지 않는다. 초과 시 409DEVICE_LIMIT_EXCEEDED. - 서버 자동 회수 없음(D14) — 오프라인과 폐기를 구분할 수 없어 잘못 회수하면 재생 중인 기기가 끊긴다. 정리는 점장이 기기 목록에서 명시적으로 한다.
store_device_playlist (기기별 활성 PL, V58)
| 컬럼 | 타입 | 비고 |
|---|---|---|
device_id | uuid PK, FK → store_device | 기기당 1행 |
playlist_id | uuid FK → playlist, nullable | NULL = “명시적으로 매장 기본을 따름”(행을 지운 것과 결과는 같지만 점장이 [본사 기본으로] 를 누른 사실이 applied_at 과 함께 남는다) |
applied_at | timestamptz | 지정 시각 |
created_at·updated_at | timestamptz |
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_id | uuid PK, FK → store_device | 기기당 1행 |
store_id | uuid FK → store · hq_id uuid FK → hq | 매장·본사 단위 집계 |
state | varchar(16) | PLAYING·PAUSED·SILENT(클라 보고). OFFLINE 은 조회 시 last_heartbeat_at staleness 로 파생(저장값 아님 — 매장 테이블과 동일 규칙) |
current_music_id | uuid, nullable, FK → music | |
last_heartbeat_at·updated_at | timestamptz |
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_atstaleness 로 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_id | uuid FK → announcement_dispatch | PRIMARY KEY(dispatch_id, device_id) |
device_id | uuid FK → store_device | |
outcome | varchar(16) | DispatchAckOutcome PLAYED·FAILED·SKIPPED 재사용 |
acked_at·updated_at | timestamptz |
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).
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 송출 row id(= dispatchId, ack 시 사용) |
announcement_id | uuid FK → tts_announcement | 송출된 안내방송 |
hq_id | uuid FK → hq | 송출 주체 본사(스코프 검증) |
store_id | uuid FK → store | 수신 매장(매장당 1 row 로 fan-out) |
status | varchar | DispatchStatus(SCHEDULED/PENDING/PLAYED/CANCELED, SPEC #077 + #078 4종 확장). 즉시 송출은 PENDING 생성. 예약 송출(#078)은 SCHEDULED 생성 → 디스패처가 도래 시 PENDING 전이 → ack(점장) → PLAYED · 본사 취소 → CANCELED (SCHEDULED 도 CANCELED 직행 가능) |
created_at | timestamp | 송출 row 생성 시각(점장 pending 목록 정렬 = created_at ASC). 예약 송출이면 이 값이 등록 시각이고, 실 송출 시각은 scheduled_at |
played_at | timestamp nullable | ack(재생 완료) 시각. SCHEDULED/PENDING/CANCELED 동안 null |
scheduled_at | timestamp 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_at | timestamp 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_id | uuid 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_source | varchar(16) nullable | (SPEC #180, V68) 기기가 이 송출을 알게 된 채널 — SSE(실시간 통지 스트림 = 가속 채널) / POLLING(주기 폴링 = 안전망). ack 옵션 필드로 클라이언트가 보고한다. NULL = 미보고(구버전·계측 이전 row) — POLLING 으로 간주하지 않는다 |
signal_received_at | timestamptz nullable | (SPEC #180, V68) 기기가 신호를 받은 시각(클라이언트 보고 · 큐 응답 serverNowIso 앵커로 서버 시각 보정). NULL = 미보고 |
playback_started_at | timestamptz nullable | (SPEC #180, V68) 기기가 실제로 재생을 시작한 시각(오버레이 playing · 서버 시각 보정). play_at(지시한 시각) 대비 편차 실측 = 전 기기 동시 재생 품질 확인. NULL = 미보고 |
signal_latency_ms | integer 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_id | uuid 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 는 모두 404DISPATCH_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_idFK 가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 의미로 굳어 의미 오염·권한 문제 회피.
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 감사 행 id |
occurred_at | timestamptz NOT NULL | 발생 시각 (정렬 키) |
created_at | timestamptz NOT NULL | INSERT 시각 (Auditing) |
hq_id | uuid FK → hq | 테넌트 격리(쿼리에 WHERE hq_id 강제) |
actor_account_id | uuid FK → operator_account | 행위자 본사 매니저 계정 |
actor_email | varchar(255) NOT NULL | 행위자 이메일 스냅샷(계정 후속 삭제 대비) |
actor_role | varchar(32) NOT NULL | HQ_MANAGER (직접) / OPERATOR_IMPERSONATING (운영자 위장 중) |
impersonated_by_operator_id | uuid FK → operator_account nullable | OPERATOR_IMPERSONATING 일 때만 — 원본 운영자 |
impersonated_by_email | varchar(255) nullable | 원본 운영자 이메일 스냅샷 |
action | varchar(48) NOT NULL | HQ_ANNOUNCEMENT_DISPATCHED (현재 1종) |
target_type | varchar(32) NOT NULL | TTS_ANNOUNCEMENT |
target_id | uuid nullable | 안내방송 id |
target_label | varchar(255) nullable | 대상 라벨 스냅샷(안내방송 제목) |
detail | varchar(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.record1줄 (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_id →
store_id 로 바뀌고 actor 컬럼·임퍼소네이션 정합성·인덱스 전략이 동일하다. operator_audit_log(운영자
액션)·hq_audit_log(본사 액션) 와 의미가 분리된 별도 테이블 — actor·테넌트 스코프(store_id)·UI 권한
모델이 다르다.
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 감사 행 id |
occurred_at | timestamptz NOT NULL | 발생 시각 (정렬 키) |
created_at | timestamptz NOT NULL | INSERT 시각 (Auditing) |
store_id | uuid FK → store | 테넌트 격리(조회 쿼리에 WHERE store_id 강제 — 후속 view) |
actor_account_id | uuid FK → operator_account | 행위자 점장 계정(점장·운영자 모두 operator_account 테이블) |
actor_email | varchar(255) NOT NULL | 행위자 이메일 스냅샷(계정 후속 변경 대비) |
actor_role | varchar(32) NOT NULL | STORE_MANAGER (직접) / OPERATOR_IMPERSONATING (운영자 점장 모드 위장 중) |
impersonated_by_operator_id | uuid FK → operator_account nullable | OPERATOR_IMPERSONATING 일 때만 — 원본 운영자 |
impersonated_by_email | varchar(255) nullable | 원본 운영자 이메일 스냅샷 |
action | varchar(48) NOT NULL | StoreAuditAction 5종 (아래) |
target_type | varchar(32) NOT NULL | SUPPORT_TICKET · STORE_MANAGER(self) · DISPATCH |
target_id | uuid nullable | ticket id · 점장 계정 id · dispatch id |
target_label | varchar(255) nullable | 대상 라벨 스냅샷(ticket 제목 등) |
detail | varchar(1024) nullable | 액션 상세 스냅샷(액션별 — 아래) |
- action 5종 + 기록 hook (모두 액션 성공 직후 같은 트랜잭션 안에서
StoreAuditService.record1줄, 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_email은OperatorAccountRepository.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).
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 템플릿 id |
store_id | uuid FK → store | 소유 매장(토큰 주체에서 도출, 타 매장 비노출) |
name | varchar(60) | 표시 이름 (1~60자) |
text | varchar(200) | 방송할 텍스트 (1~200자, 즉시방송 text 와 동일 제약) |
voice | varchar | TtsVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA) |
created_at/updated_at | timestamp | Auditing. 목록 정렬 = updated_at DESC |
deleted_at | timestamp nullable | soft-delete. 활성 = deleted_at IS NULL |
- 매장당 활성 ≤20: 생성 시 활성 count 를 검증해 상한(20) 도달 시 409
BROADCAST_TEMPLATE_LIMIT_EXCEEDED. FE 는total>=20이면 [+ 새 템플릿]을 사전 비활성하고 안내 배너를 띄운다. - 소유 은닉: 수정/삭제 대상이 본인 매장 활성 템플릿이 아니면(미존재·삭제·재삭제·타 매장) 404
BROADCAST_TEMPLATE_NOT_FOUND(존재·소유 은닉). - 로드 전용 재사용: 템플릿은 합성/송출하지 않고 즉시방송 TTS 탭 입력(text·voice)을 채우는 로드까지만
쓰인다 — 미리듣기·송출은 즉시방송 게이트(
previewStoreBroadcast→sendStoreBroadcast)가 맡는다. - 화면·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).
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | uuid PK | 앱에서 시간순 UUID 선생성(음원 #041·TTS 안내방송 V21 패턴 — F1 후속에서 blob key=PK 정책 검토) |
hq_id | uuid NOT NULL | 소유 본사. 조회/수정/삭제 시 토큰 hqId 와 일치 검증(verifyHqScope, 타 본사 404 은닉) |
title | varchar(200) NOT NULL | 표시명(1~200) |
audio_url | text 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_seconds | integer NOT NULL | 오디오 길이(초) — 1..3600(1시간 이하) |
is_active | boolean NOT NULL DEFAULT TRUE | 점장 player 사이클 재생 대상 토글(#094 라운드로빈 후보 필터). 생성 시 true 기본 |
created_at·updated_at | timestamptz NOT NULL | UTC |
deleted_at | timestamptz nullable | soft-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차 경계 + serviceverifyHqScopeclaim↔DB 재검증(미인증 401 · 소속 불일치 403PRINCIPAL_SCOPE_MISMATCH). - 격리 불변식 — 모든 endpoint 가
WHERE hq_id = :hqId AND deleted_at IS NULL강제(BE D3). 타 본사 row·삭제 row 는 404COMMERCIAL_SONG_NOT_FOUND로 존재 은닉. - 수정 부분 갱신(PATCH) —
title?: string|null·isActive?: boolean|null(null=미변경, BE D2). 두 필드 모두 null = no-op(현재 상태 그대로 200). 오디오(audio_url·duration_seconds) 교체는 별도 endpointPUT /{id}/file(#174, multipart) — 같은 blob key 덮어쓰기로 내용·길이 갱신(PATCH 는 메타만). 등록·교체는 auditHQ_COMMERCIAL_CREATED·HQ_COMMERCIAL_FILE_REPLACED로 남는다. - 삭제 = soft-delete —
UPDATE ... WHERE id AND hq_id AND deleted_at IS NULL(affected=0 → 404COMMERCIAL_SONG_NOT_FOUND). blob 유지·hard purge 는 후속. - 화면·CRUD 흐름은 HQ Mode CM송 관리 · HqCommercial DTOs 참조.
Auditing
BaseEntity.createdBy/updatedBy—AuditorAware<UUID>→ 현재 OperatorAccount.idBaseEntity.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(✅ (#058 — V19 컬럼 + partial unique index, 점장 큐 fallback) · status 5종 파생은 후속(F3)is_default)- 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