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(본사 화면 비노출 — 과노출 차단). 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 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. |
| 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. |
핵심 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 = 미적용. 단일 활성(교체 = 덮어쓰기) |
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 플레이리스트 조회 참조.
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 안내방송 참조.
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 쿼리 가속 |
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 해 무한 멈춤을 막는다. - 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 의 음악 큐 사이에 사이클 삽입” 하는 다른 도메인이다(본 슬라이스는 백본 + 본사 관리 UI 만 —
점장 player 삽입은 F1 후속). 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(≤2048). FE 가 음원 업로드 후 받은 Azure blob URL — 본 슬라이스는 음원 업로드 UI 미포함, 사용자가 공개 접근 URL 을 직접 입력한다(SPEC §D9 가드, F1 후속에서 업로드 흐름 추가) |
duration_seconds | integer NOT NULL | 오디오 길이(초) — 1..3600(1시간 이하) |
is_active | boolean NOT NULL DEFAULT TRUE | F1 점장 player 사이클 삽입 대상 토글. 생성 시 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). audioUrl 변경은 후속(재업로드 흐름 별도). 두 필드 모두 null = no-op(현재 상태 그대로 200). - 삭제 = 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