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(본사 화면 비노출 — 과노출 차단). PostgreSQL ADD COLUMN without DEFAULT = metadata-only. 운영자/본사 view 의 category 표면화는 후속(이번엔 컬럼 공용 추가·점장 채널만 소비). 상세는 Store Customer Support · Store CS Ticket 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. | | 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. |

핵심 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 = 미적용. 단일 활성(교체 = 덮어쓰기)
  • 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 플레이리스트 조회 참조.

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 안내방송 참조.

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 쿼리 가속
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 해 무한 멈춤을 막는다.
  • 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 의 음악 큐 사이에 사이클 삽입” 하는 다른 도메인이다(본 슬라이스는 백본 + 본사 관리 UI 만 — 점장 player 삽입은 F1 후속). 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(≤2048). FE 가 음원 업로드 후 받은 Azure blob URL — 본 슬라이스는 음원 업로드 UI 미포함, 사용자가 공개 접근 URL 을 직접 입력한다(SPEC §D9 가드, F1 후속에서 업로드 흐름 추가)
duration_secondsinteger NOT NULL오디오 길이(초) — 1..3600(1시간 이하)
is_activeboolean NOT NULL DEFAULT TRUEF1 점장 player 사이클 삽입 대상 토글. 생성 시 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). audioUrl 변경은 후속(재업로드 흐름 별도). 두 필드 모두 null = no-op(현재 상태 그대로 200).
  • 삭제 = 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