API 카탈로그Endpoints

API Endpoints (전체 카탈로그)

기준 BE v0.60 — 2026-06-10. backend linkmusic-msa-space-was/.../api/. OpenAPI 단일 소스 — /v3/api-docs.

Overview

backend 의 모든 endpoint 카탈로그. Springdoc 이 자동 생성하는 OpenAPI 가 진실. 이 페이지는 sync.

SPEC #028 — OpenAPI 완성도 (BE v0.20.2)

generated TS client(packages/api-client)의 훅·타입 이름은 backend 의 OpenAPI operationId 에서 파생된다. v0.20.2 에서 다음이 정비됐다 — FE 호출부 영향 큼.

  • operationId 유니크화 — 모든 endpoint 가 @Operation(operationId = ...) 로 명시 이름을 갖는다. 이전에는 메서드명 충돌로 orval 이 usePublish/usePublish1, useCreate/useList 같은 모호·접미사 이름을 생성했다. 이제 endpoint 별로 안정적 고유 이름 → generated 훅/QueryKey/Params 타입이 endpoint 의미를 그대로 반영한다. 각 endpoint 의 operationId 는 아래 표 operationId 열 참조.
  • 응답 DTO 스키마 복구 — 일부 endpoint 의 응답 스키마가 OpenAPI 에 누락돼 있던 것을 복구. 성공 응답은 generated 응답 타입이 { data: <DTO>, status, headers } envelope 의 성공/에러 union 으로 표현된다(예: getAdminStatsResponse = Success(200) | Error(401|403|500)). FE 는 status === 200 으로 좁혀 DTO 를 확정한다.
  • @Schema(nullable = true) 명시 — 운영상 비어 있을 수 있는 필드(TicketListItem.hqName·storeName·category·assigneeEmail, HqAdminListItem.paymentDueDays, 매장 lastOnlineAt·매니저 lastLoginAt 등)가 OpenAPI 에 nullable 로 표기 → generated 타입이 T | null. FE 는 ?? undefined/?? "—"/== null 가드로 처리(없으면 “미배정”/”—” 표시).
  • JWT Bearer 인증(Authorize) — Swagger UI 에 bearerAuth(HTTP Bearer, JWT) security scheme 이 등록됨. 보호 endpoint 는 Authorize 버튼으로 access token 을 넣고 직접 호출 가능. public 표기 외 모든 endpoint 는 Authorization: Bearer <accessToken> 필요.
  • 표준 에러 응답 envelope — 모든 4xx/5xx 가 GlobalExceptionHandler{ success: false, error: { code, message, details? } } 형태로 통일(코드 카탈로그는 Error Codes 참조). OpenAPI 에 status 별 에러 응답이 문서화됨.
  • Swagger example — 주요 endpoint 에 성공/에러 응답 example 이 OpenAPI 에 포함돼 Swagger UI 에서 바로 확인 가능.

operationId ↔ generated 이름 (대표)

operationIdgenerated 훅 / 타입
listTicketsuseListTickets · ListTicketsParams · ListTicketsStatus · ListTicketsPriority · ListTicketsCategory
getTicketDetailuseGetTicketDetail · TicketDetail
createTicketuseCreateTicket
addTicketCommentuseAddTicketComment
changeTicketStatususeChangeTicketStatus
changeTicketPriorityuseChangeTicketPriority
assignTicketuseAssignTicket
suspendHq / reactivateHquseSuspendHq / useReactivateHq
suspendStore / reactivateStoreuseSuspendStore / useReactivateStore
closeStoreuseCloseStore
issueStoreManageruseIssueStoreManager
publishTerms / publishPrivacyPolicyusePublishTerms / usePublishPrivacyPolicy
listTermsHistory / listPrivacyPolicyHistoryuseListTermsHistory / useListPrivacyPolicyHistory
getTermsVersion / getPrivacyPolicyVersionuseGetTermsVersion / useGetPrivacyPolicyVersion
cancelTermsSchedule / cancelPrivacyPolicyScheduleuseCancelTermsSchedule / useCancelPrivacyPolicySchedule
getAdminStatsuseGetAdminStats · getGetAdminStatsQueryKey
listImpersonationAuditListImpersonationAuditParams · ListImpersonationAuditStatus
listOperatorActionAuditListOperatorActionAuditParams · ListOperatorActionAuditAction · ListOperatorActionAuditTargetType

아래 표의 operationId 는 OpenAPI(@Operation(operationId=...)) 기준 — generated 훅/타입 이름의 근원이다. public 외 모든 endpoint 는 JWT Bearer 인증 필요.

Auth (/api/v1/auth/*)

MethodPathoperationIdAuth도입
POST/api/v1/auth/loginloginpublic#003
POST/api/v1/auth/refreshrefreshpublic#003
POST/api/v1/auth/logoutlogoutpublic (refresh 식별)#003
GET/api/v1/auth/memeauthenticated#003 (+ #005 flag 확장)
POST/api/v1/auth/change-passwordchangePasswordauthenticated#007
POST/api/v1/auth/impersonate-exchangeimpersonateExchangepublic#005

Admin — Terms · Privacy (/api/v1/admin/*)

MethodPathoperationIdAuth도입
POST/api/v1/admin/termspublishTermsOPERATOR#004 (#033 effectiveAt 예약·status 응답 추가)
POST/api/v1/admin/privacy-policypublishPrivacyPolicyOPERATOR#004 (#033 동상)
GET/api/v1/admin/termslistTermsHistoryOPERATOR#033 — 예약본 포함 전체 이력 LegalDocumentHistoryResponse(본문 제외). 정렬 effectiveAt DESC, id DESC
GET/api/v1/admin/privacy-policylistPrivacyPolicyHistoryOPERATOR#033 동상
GET/api/v1/admin/terms/{id}getTermsVersionOPERATOR#033 — 단건 본문 포함 LegalDocumentResponse. 미존재 404 LEGAL_DOC_NOT_FOUND
GET/api/v1/admin/privacy-policy/{id}getPrivacyPolicyVersionOPERATOR#033 동상
DELETE/api/v1/admin/terms/{id}cancelTermsScheduleOPERATOR#038 — 미발효 예약본(SCHEDULED·effective_at > now) hard-delete. 204 No Content. 409 LEGAL_DOC_NOT_SCHEDULED·404 LEGAL_DOC_NOT_FOUND
DELETE/api/v1/admin/privacy-policy/{id}cancelPrivacyPolicyScheduleOPERATOR#038 동상

Public — Terms · Privacy

MethodPathoperationIdAuth도입
GET/api/v1/terms/activegetActiveTermspublic#004 (#033 의미변경 — “현재 유효본” = effective_at <= now 중 최신. 응답 status 는 항상 ACTIVE 보정)
GET/api/v1/privacy-policy/activegetActivePrivacyPolicypublic#004 (#033 동상)
MethodPathoperationIdAuth도입
POST/api/v1/legal/terms/agreeagreeTerms인증(본인)#040 — 현재 유효 이용약관 재동의. LegalAgreeRequest { version }204 No Content(멱등). 활성판 불일치 400 INVALID_LEGAL_VERSION·임퍼소네이션 403·OPERATOR 403. role 별 consent INSERT(ip·UA·signerName 서버 자동 캡처)
POST/api/v1/legal/privacy/agreeagreePrivacy인증(본인)#040 동상 — 현재 유효 개인정보처리방침 재동의. 204(멱등)

Admin — Hq (/api/v1/admin/hq*)

MethodPathoperationIdAuth도입
POST/api/v1/admin/hqonboardHqOPERATOR#005 / #010 / #013 (businessNumber)
GET/api/v1/admin/hqlistHqsOPERATOR#011 (minimal — id+name, dropdown 전용. ⚠️ admin-list 와 별개 — 검색·페이지네이션 없음)
GET/api/v1/admin/hq/admin-listadminListHqsOPERATOR#013 (풀 필드 — 운영사 본사 목록). #045: 검색·필터·페이지네이션 query q(본사명·사업자번호 부분일치, max100)·status(ACTIVE|ONBOARDING|UNPAID|SUSPENDEDtype(FRANCHISE|INDEPENDENTplan(AI|TRUSTpage(0-base)·size(1..100 clamp, 기본 20). 응답 envelope HqAdminListResponse{ items, page, size, total }. 정렬 status 우선순위(UNPAID→SUSPENDED→ACTIVE→ONBOARDING)→name asc→id asc 고정. status 미지정 = 가상 본사 포함 전체
GET/api/v1/admin/hq/{id}getHqDetailOPERATOR#020 (HqDetailResponse — 단건 상세: 개요 + managers + storeCount. 존재 X → 404 HQ_NOT_FOUND)
GET/api/v1/admin/hq/{id}/consentsgetHqConsentsOPERATORSPEC #127 — 협약(본사 동의 이력·상태) read-only. HqConsentsResponse{ hqId, hqName, status(HqStatus), terms: ConsentRecord[], privacy: ConsentRecord[], termsReagreeRequired, privacyReagreeRequired }. ConsentRecord{ documentVersion, agreedAt, signerName?, agreedIp? }(동의 시각 내림차순). 재동의 필요 = 현재 유효 약관/개인정보 버전 > 최신 동의 버전(기존 유효버전 로직 재사용). 기존 HqConsent·HqPrivacyConsent·HqStatus 재사용 — 마이그레이션 0. 미존재 → 404 HQ_NOT_FOUND. 결제(단가·청구·만료)는 v1 제외(무료 MVP). FE: /contracts 본사 드롭다운(useListHqs) + 선택 본사 동의 이력 표 — Contracts
PATCH/api/v1/admin/hq/{id}updateHqOPERATORSPEC #123 — 본사명 편집(#085 F1). body UpdateHqRequest{name}(비-blank·≤255자, Hq.name 생성 제약 미러) → 200 HqDetailResponse(read-back). 인터뷰 결정(2026-06-16): 본사명 변경 = 운영사만(본사 자기수정 불가 — updateHqMe 는 매니저 계정명만). 원자적 HqRepository.updateName. 운영자 audit OperatorAuditAction.HQ_UPDATED 1행. 미존재 → 404 HQ_NOT_FOUND · 검증 위반 → 400. claim 재검증 403 · 미인증 401. generated 훅 useUpdateHq. apps/admin /hq/[id] 개요 본사명 인라인 편집(헤더 [편집] 토글 → input → [저장], 성공 시 router.refresh()).
POST/api/v1/admin/hq/{hqId}/impersonateimpersonateHqOPERATOR#005
POST/api/v1/admin/hq/{hqId}/suspendsuspendHqOPERATOR#018 · #024 (body SuspendRequest{ reason } 필수 — HqStatusResponse ACTIVE|ONBOARDING|UNPAID → SUSPENDED + 세션 무효화 + 정지 사유 저장)
POST/api/v1/admin/hq/{hqId}/reactivatereactivateHqOPERATOR#018 · #024 (body 없음 — HqStatusResponse SUSPENDED → ACTIVE + suspensionReason clear)

Admin — Stores (/api/v1/admin/stores*)

MethodPathoperationIdAuth도입
POST/api/v1/admin/stores/independentonboardIndependentStoreOPERATOR#005 / #011
POST/api/v1/admin/hq/{hqId}/storesonboardAffiliatedStoreOPERATOR#011 (가맹 매장 등록 — DIRECT/FRANCHISE, HqAffiliatedStoreOnboardingRequest)
GET/api/v1/admin/stores/admin-listadminListStoresOPERATOR#019 (풀 필드 — 운영사 매장 목록, hqName join). #044: 검색·필터·페이지네이션 query q(매장명·본사명·주소 부분일치, max100)·status(ACTIVE|SUSPENDED|INACTIVEtype(DIRECT|FRANCHISE|INDEPENDENTplan(AI|TRUSThqId(#020 — 특정 본사 스코프, 미지정 시 전체)·page(0-base)·size(1..100 clamp, 기본 20). 응답 envelope StoreAdminListResponse{ items, page, size, total }. 정렬 status 우선순위(SUSPENDED→INACTIVE→ACTIVE)→name asc→id asc 고정. 폐점(closedAt) 매장 포함(status 미지정 = 전체). hasManagerAccount·closedAt derived 필드 포함 (#021·#044)
GET/api/v1/admin/stores/{id}getStoreDetailOPERATOR#036 (StoreDetailResponse — 단건 매장 상세: 개요(hqName join) + 소속 STORE_MANAGER managers(SUSPENDED·WITHDRAWN 포함)). 존재 X → 404 STORE_NOT_FOUND. HQ 상세 getHqDetail 대칭
POST/api/v1/admin/stores/{storeId}/suspendsuspendStoreOPERATOR#037 (매장 정지 — body StoreSuspendRequest{ reason NotBlank·max255 }StoreStatusResponse, ACTIVE|INACTIVE → SUSPENDED + 소속 점장 세션 무효화 + 정지 사유 영속). 전이 불가 → 409 STORE_INVALID_STATUS_TRANSITION · 미존재 → 404 STORE_NOT_FOUND. HQ suspendHq 대칭
POST/api/v1/admin/stores/{storeId}/reactivatereactivateStoreOPERATOR#037 (매장 복구 — body 없음 → StoreStatusResponse, SUSPENDED → ACTIVE + suspensionReason clear). 전이 불가 → 409 STORE_INVALID_STATUS_TRANSITION · 미존재 → 404. HQ reactivateHq 대칭
POST/api/v1/admin/stores/{storeId}/closecloseStoreOPERATOR#039 (매장 폐점 — body StoreCloseRequest{ reason NotBlank·max255 }StoreCloseResponse{ storeId, closedAt }, closed_at 채움(비가역 terminal, status 변경 X) + 소속 점장 세션 무효화 + 이후 로그인/refresh 영구 차단(AUTH_STORE_CLOSED)). 이미 폐점 → 409 STORE_ALREADY_CLOSED · 미존재 → 404 STORE_NOT_FOUND. suspend/reactivate WHERE 에 AND closed_at IS NULL 가드 추가
POST/api/v1/admin/stores/{storeId}/managersissueStoreManagerOPERATOR#021 (점장 STORE_MANAGER 계정 발급 — StoreManagerIssueRequestStoreManagerIssueResponse). 매장 X → 404 STORE_NOT_FOUND · email 중복 → 409 DUPLICATE_EMAIL
GET/api/v1/admin/stores/{storeId}/managerslistStoreManagersOPERATOR#029 (매장 점장 목록 — StoreManagerListResponse { items: StoreManagerListItem[] }, createdAt/id asc). 매장 X → 404 STORE_NOT_FOUND
POST/api/v1/admin/stores/{storeId}/managers/{managerId}/reset-passwordresetStoreManagerPasswordOPERATOR#029 (점장 임시 비밀번호 재설정 — ResetPasswordRequest { tempPassword 8~100 }StoreManagerAccountResponse). ACTIVE 계정만 (SUSPENDED/WITHDRAWN → 409 ACCOUNT_INVALID_STATUS_TRANSITION). 미존재 → 404 STORE_MANAGER_NOT_FOUND
POST/api/v1/admin/stores/{storeId}/managers/{managerId}/suspendsuspendStoreManagerOPERATOR#029 (점장 정지 — StoreManagerSuspendRequest { reason NotBlank·max255 }, ACTIVE→SUSPENDED). 잘못된 전이 → 409 ACCOUNT_INVALID_STATUS_TRANSITION · 미존재 → 404
POST/api/v1/admin/stores/{storeId}/managers/{managerId}/reactivatereactivateStoreManagerOPERATOR#029 (점장 복구 — SUSPENDED→ACTIVE, body 없음). 잘못된 전이 → 409 ACCOUNT_INVALID_STATUS_TRANSITION · 미존재 → 404
DELETE/api/v1/admin/stores/{storeId}/managers/{managerId}revokeStoreManagerOPERATOR#029 (점장 회수 — ACTIVE/SUSPENDED→WITHDRAWN, terminal). 잘못된 전이 → 409 ACCOUNT_INVALID_STATUS_TRANSITION · 미존재 → 404
GET/api/v1/admin/stores/{storeId}/active-playlistgetStoreActivePlaylistOPERATOR#055 (매장 활성 플레이리스트 조회 — StoreActivePlaylistResponse{ active, playlistId?, name?, libraryCount?, appliedAt? }. 미적용 시 active=false·나머지 null). 매장 X → 404 STORE_NOT_FOUND. FE: 매장 상세 개요 탭 “활성 플레이리스트” 섹션이 useGetStoreActivePlaylist(storeId)(client query)로 표시 — Store 상세
PUT/api/v1/admin/stores/{storeId}/active-playlistsetStoreActivePlaylistOPERATOR#055 (매장 활성 플레이리스트 적용 — body SetStoreActivePlaylistRequest{ playlistId }200 StoreActivePlaylistResponse(적용 PL 요약)). 매장당 단일 활성(기존 적용 덮어쓰기 = 교체)·공유 모델(PL 참조, 복사본 아님). 적용 대상 PL 은 매장 소속 본사(hqId)의 활성 PL 이어야 함. 매장 X → 404 STORE_NOT_FOUND · PL 미존재·삭제 → 404 PLAYLIST_NOT_FOUND · hqId 불일치 → 409 STORE_PLAYLIST_HQ_MISMATCH. FE: 후보 PL 목록을 매장 hqId 로 한정(useListPlaylists({ hqId }))해 사고 방지
DELETE/api/v1/admin/stores/{storeId}/active-playlistclearStoreActivePlaylistOPERATOR#055 (매장 활성 플레이리스트 해제 — active_playlist_id NULL, 204, 멱등 — 이미 미적용이어도 성공). 매장 X → 404 STORE_NOT_FOUND. (PL 소프트삭제 시 그 PL 을 활성으로 쓰던 매장은 #054 deletePlaylist 에서 자동 해제 — SPEC #055 D7)

Admin — Operators (/api/v1/admin/operators*)

운영자(OPERATOR) 계정 관리 (#032). OPERATOR-only. AccountStatus 라이프사이클(ACTIVE ↔ SUSPENDED, → WITHDRAWN terminal)은 점장(#029)과 동일하되, 본인 계정 보호(403 OPERATOR_SELF_ACTION_FORBIDDEN)와 마지막 활성 운영자 보호(403 OPERATOR_LAST_ACTIVE_FORBIDDEN)가 추가된다. 정지·재설정·회수는 대상 활성 세션을 즉시 무효화한다.

MethodPathoperationIdAuth도입
GET/api/v1/admin/operatorslistOperatorsOPERATOR#032 · #043 (운영자 목록 — 검색·상태필터·페이지네이션). Query: q?(email·name 부분일치, 대소문자 무시, max 100) · status?(ListOperatorsStatus ACTIVE/SUSPENDED/WITHDRAWN — 미지정 시 전체, WITHDRAWN 포함) · page(0-base, default 0) · size(default 20, 1..100 clamp). 응답 envelope OperatorListResponse { items: OperatorListItem[], page, size, total }, 정렬 createdAt asc → id asc. isSelf(본인 여부, FE 자기 행 액션 비활성용) 파생 필드 포함. q 길이/잘못된 status·page·size → 400
POST/api/v1/admin/operatorsissueOperatorOPERATOR#032 (운영자 초대/생성 — OperatorIssueRequest { email, name, tempPassword 8~100 }OperatorIssueResponse { id }, 201, passwordMustChange=true). email 중복 → 409 DUPLICATE_EMAIL
POST/api/v1/admin/operators/{operatorId}/reset-passwordresetOperatorPasswordOPERATOR#032 (임시 비밀번호 재설정 — OperatorResetPasswordRequest { tempPassword 8~100 }OperatorAccountResponse, passwordMustChange=true). ACTIVE 계정만 (SUSPENDED/WITHDRAWN → 409 ACCOUNT_INVALID_STATUS_TRANSITION). 본인 → 403 OPERATOR_SELF_ACTION_FORBIDDEN · 미존재 → 404 OPERATOR_NOT_FOUND
POST/api/v1/admin/operators/{operatorId}/suspendsuspendOperatorOPERATOR#032 (정지 — OperatorSuspendRequest { reason NotBlank·max255 }, ACTIVE→SUSPENDED). 본인 → 403 OPERATOR_SELF_ACTION_FORBIDDEN · 마지막 활성 → 403 OPERATOR_LAST_ACTIVE_FORBIDDEN · 잘못된 전이 → 409 · 미존재 → 404
POST/api/v1/admin/operators/{operatorId}/reactivatereactivateOperatorOPERATOR#032 (복구 — SUSPENDED→ACTIVE, body 없음). 잘못된 전이 → 409 ACCOUNT_INVALID_STATUS_TRANSITION · 미존재 → 404
DELETE/api/v1/admin/operators/{operatorId}revokeOperatorOPERATOR#032 (회수 — ACTIVE/SUSPENDED→WITHDRAWN, terminal soft-delete). 본인 → 403 OPERATOR_SELF_ACTION_FORBIDDEN · 마지막 활성 → 403 OPERATOR_LAST_ACTIVE_FORBIDDEN · 이미 WITHDRAWN → 409 · 미존재 → 404

Admin — Stats (/api/v1/admin/stats)

MethodPathoperationIdAuth도입
GET/api/v1/admin/statsgetAdminStatsOPERATOR#017 (ops 대시보드 핵심 지표 — AdminStatsResponse). #035 확장: storesByStatus·hqsByType·accounts·tickets·hqSignups30d. #126 확장: dispatch(DispatchStatsCounts — status 분포 + 최근 7일 송출)·dispatchTrend30d(DailyCount[]storeAuditActivity7d 추가(operationId·경로·인가·기존 필드 불변). 파라미터 없음(고정 30일 추이). 추이는 있는 날만 반환 — FE 가 30일 축에 0 채움. 도달률·티켓 해결률은 FE 파생(BE 비율 필드 없음)

Admin — TTS (/api/v1/admin/tts*)

MethodPathoperationIdAuth도입
POST/api/v1/admin/tts/smoke-testrunTtsSmokeTestOPERATOR#134 (TTS 연동 진단 — 고정 텍스트로 부작용 없는 합성 1회, blob·DB 미저장 → TtsSmokeTestResponse). 토큰 미설정 503 TTS_TOKEN_NOT_CONFIGURED · 외부 실패 502 TTS_SYNTHESIS_FAILED. 운영자 수동 전용(quota 보호). env TYPECAST_API_TOKEN 필요

Admin — Audit (/api/v1/admin/audit*)

MethodPathoperationIdAuth도입
GET/api/v1/admin/audit/impersonationlistImpersonationAuditOPERATOR#025 (임퍼소네이션 감사 세션 조회 — ImpersonationAuditListResponse). query from·to·operatorId·hqId·status·page·size. status(ACTIVE/COMPLETED/EXPIREDdurationSec 는 backend 파생. 정렬 startedAt desc. append-only(수정/삭제 endpoint 없음)
GET/api/v1/admin/audit/impersonation/exportexportImpersonationAuditOPERATOR#074 — 임퍼소네이션 감사 CSV 내보내기 (#048 패턴 완전 미러). 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename*=UTF-8''임퍼소네이션감사_{yyyyMMdd_HHmmss}.csv(RFC5987 percent-encoded · 한글 파일명) + `X-Export-Truncated: true
GET/api/v1/admin/audit/actionslistOperatorActionAuditOPERATOR#026 (운영자 액션 공통 감사 로그 조회 — OperatorAuditListResponse). query from·to·action·actorOperatorId·targetType·q·page·size. q(SPEC #034, max 100): targetLabel+detail 부분일치(ILIKE, 대소문자 무시) free-text 검색 — blank 무관, 길이 위반 400. 기록 액션 action(HQ_SUSPENDED/HQ_REACTIVATED/STORE_MANAGER_ISSUED/STORE_MANAGER_PASSWORD_RESET/STORE_MANAGER_SUSPENDED/STORE_MANAGER_REACTIVATED/STORE_MANAGER_REVOKED/OPERATOR_ISSUED/OPERATOR_PASSWORD_RESET/OPERATOR_SUSPENDED/OPERATOR_REACTIVATED/OPERATOR_REVOKED/TERMS_PUBLISHED/PRIVACY_POLICY_PUBLISHED/HQ_ONBOARDEDtargetType(HQ/STORE/OPERATOR/TERMS/PRIVACY_POLICY). 운영자 계정 액션 5종·OPERATOR 대상은 #032 추가. 정렬 occurredAt desc. append-only(수정/삭제 endpoint 없음)
GET/api/v1/admin/audit/hqlistOperatorHqAuditOPERATOR#081 — 운영사 통합 본사 audit 조회(#068 F4 마감). 모든 본사의 hq_audit_log 통합 view — WHERE hq_id 가드 없음(본사 view #067 와 정책상 분리, OPERATOR-only). 200 OperatorHqAuditListResponse{items, page, size, total} · 행 OperatorHqAuditItem{id·hqId·**hqName**(어느 본사 audit 인지 식별, hq JOIN 결과)·occurredAt·actorEmail·actorRole(HQ_MANAGER/OPERATOR_IMPERSONATING)·impersonatedByEmail?·action(OperatorHqAuditItemAction 5종 — DISPATCHED·CREATED·UPDATED·DELETED·DISPATCH_CANCELED)·targetType(OperatorHqAuditItemTargetType TTS_ANNOUNCEMENT)·targetId?·targetLabel?·detail?}. query from?·to?(ISO-8601 date-time)·hqId?(옵션 — null=전체 본사 통합)·actorAccountId?·action?(ListOperatorHqAuditActiontargetType?(ListOperatorHqAuditTargetTypeq?(targetLabel/detail 부분일치, ≤100)·page?(0-base)·size?(1..100 clamp). 정렬 occurred_at DESC, id ASC 서버 고정. HQ_MANAGER 호출 → 403. generated 훅 useListOperatorHqAudit·getListOperatorHqAuditQueryKey. apps/admin /audit/hq 페이지(필터·페이지네이션 client state · <AuditTabs> 3번째 탭) + hqName 컬럼 신규 + 본사 필터(UUID 자유 입력 — F2 select 후속). CSV 내보내기는 SPEC #088 도착.
GET/api/v1/admin/audit/hq/exportexportOperatorHqAuditOPERATOR#088 — 운영사 통합 본사 audit CSV 내보내기(#081 F1 마감 · #074 패턴 완전 미러 · CSV export 4종 완결). 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename*=UTF-8''본사통합감사_{yyyyMMdd_HHmmss}.csv(RFC5987 percent-encoded · 한글 파일명) + X-Export-Truncated: true|false 헤더(상한 신호). query 는 listOperatorHqAudit 와 동일하되 page/size 만 제외(from?·to? ISO-8601 date-time · hqId?(옵션·null=전체 본사) · actorAccountId? · action?(ExportOperatorHqAuditAction 5종 — DISPATCHED·CREATED·UPDATED·DELETED·DISPATCH_CANCELED) · targetType?(ExportOperatorHqAuditTargetType TTS_ANNOUNCEMENT) · q? ≤100). 컬럼 8종(한국어 헤더): 발생시각KST·본사명·액션(한국어 라벨 매핑 — 송출/생성/수정/삭제/송출 취소)·대상 유형(안내방송)·대상 라벨·행위자 이메일·행위자 역할(본사 매니저/운영자 위장)·상세. 상한 EXPORT_MAX=50000 행(베타 규모 충분) 초과 시 50,000+1 peek-ahead 감지 후 50,000 row 만 emit + 헤더 true. 정렬 occurred_at DESC, id ASC 서버 고정(list 와 동일 결정적). CSV injection 방어(공용 AuditCsvWriter SPEC #048/#070/#074 와 동일 모듈 — =·+·-·@·탭·CR 시작 셀에 ' prefix; new method writeOperatorHqAuditCsv). 403 ErrorResponse 는 mediaType="application/json" 명시(produces=text/csv 상속 회피 — #048 회귀 가드). HQ_MANAGER 호출 → 403 (list 와 동일 정책). generated 훅 미사용 — orval mutator(apiFetch)가 모든 응답을 res.text() → JSON 파싱이라 CSV 부적합. FE 는 공용 helper apps/admin/src/lib/csv-export.ts downloadCsvFromBackend 로 우회(브라우저에서 BFF /api/backend/v1/admin/audit/hq/export?... 직접 fetch → res.blob() → 앵커 download 속성 클릭). 401 감지 시 /login?next={현재경로+search} 풀 리다이렉트. apps/admin /audit/hq PageHeader.primary 슬롯 [CSV 내보내기] 버튼(lucide:Download·loading state “다운로드 중…” + aria-busy + disabled). 잘림 시 Banner variant="warn" “결과가 50,000행으로 잘렸습니다. 필터를 좁힌 뒤 다시 시도해 주세요.” · 실패 시 Banner variant="danger" “CSV 내보내기에 실패했습니다. 잠시 후 다시 시도해 주세요.” · 둘 다 [닫기]. AuditTabs 와 본문 사이 배치(#074 미러). 보낼 필터는 draft 가 아닌 URL applied 값 — date-only 는 KST(+09:00) 경계 정규화(from=00:00:00.000·to=23:59:59.999, #074 미러). BFF 는 catch-all [...path] 이라 신규 라우트 X. 비파괴 변경·새 env var 0.
GET/api/v1/admin/audit/storelistStoreAuditOPERATOR#115 — 운영자 통합 매장 audit 조회(#114 D5 reader 마감). 모든 매장의 store_audit_log(점장 액션 감사, #114) 통합 view — WHERE store_id 가드 없음(OPERATOR-only, hq audit #081 1:1 미러). 200 StoreAuditListResponse{items, page, size, total} · 행 StoreAuditListItem{id·storeId·**storeName**(어느 매장 audit 인지 식별, store JOIN 결과·현재값)·occurredAt·actorEmail·actorRole(STORE_MANAGER/OPERATOR_IMPERSONATING)·impersonatedByEmail?·action(StoreAuditListItemAction 5종 — STORE_TICKET_CREATED·STORE_TICKET_COMMENT_ADDED·STORE_PROFILE_UPDATED·STORE_PASSWORD_CHANGED·STORE_DISPATCH_CANCELED)·targetType(StoreAuditListItemTargetType — DISPATCH·STORE_MANAGER·SUPPORT_TICKET)·targetId?·targetLabel?·detail?}. query from?·to?(ISO-8601 date-time)·storeId?(옵션 — null=전체 매장 통합)·actorId?·action?(ListStoreAuditActionq?(targetLabel/detail 부분일치, ≤100)·page?(0-base)·size?(1..100 clamp). 정렬 occurred_at DESC, id DESC 서버 고정. 비OPERATOR 호출 → 403(미인증 401). generated 훅 useListStoreAudit·getListStoreAuditQueryKey. apps/admin /audit/store 페이지(필터·페이지네이션 client state · <AuditTabs> 4번째 탭 “매장 audit”) + storeName 컬럼 + 매장 필터(storeId UUID 자유 입력).
GET/api/v1/admin/audit/store/exportexportStoreAuditOPERATOR#115 — 운영자 통합 매장 audit CSV 내보내기(#088 hq audit 패턴 미러). 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename*=UTF-8''매장감사_{yyyyMMdd_HHmmss}.csv(RFC5987 percent-encoded · 한글 파일명) + X-Export-Truncated: true|false 헤더(상한 신호). query 는 listStoreAudit 와 동일하되 page/size 만 제외(from?·to? · storeId?(옵션·null=전체 매장) · actorId? · action?(ExportStoreAuditAction 5종) · q? ≤100). 컬럼 8종(한국어 헤더): 발생시각KST·매장명·액션(한국어 라벨 — 티켓 생성/티켓 댓글/프로필 수정/비밀번호 변경/송출 취소)·대상 유형·대상 라벨·행위자 이메일·행위자 역할(점장/운영자 위장)·상세. 상한 EXPORT_MAX=50000 행 초과 시 잘림 + 헤더 true. 정렬 occurred_at DESC, id DESC 서버 고정. CSV injection 방어(공용 AuditCsvWriter SPEC #048/#070/#074/#088 와 동일 모듈). 비OPERATOR 호출 → 403. generated 훅 미사용 — orval mutator(apiFetch)가 모든 응답을 res.text() → JSON 파싱이라 CSV 부적합. FE 는 공용 helper apps/admin/src/lib/csv-export.ts downloadCsvFromBackend 로 우회(BFF /api/backend/v1/admin/audit/store/export?... 직접 fetch → res.blob() → 앵커 download). apps/admin /audit/store PageHeader.primary 슬롯 [CSV 내보내기] 버튼 + 잘림 Banner variant="warn" “결과가 50,000행으로 잘렸습니다…” · 실패 Banner variant="danger" “CSV 내보내기에 실패했습니다…”. 보낼 필터는 applied 값 — date-only 는 KST(+09:00) 경계 정규화(#088 미러). BFF catch-all 이라 신규 라우트 X. 비파괴 변경·새 env var 0.
GET/api/v1/admin/audit/actions/exportexportOperatorActionsOPERATOR#048 — 운영자 액션 감사 CSV 내보내기. 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename="audit-actions-{yyyyMMdd-HHmmss}.csv"; filename*=UTF-8''...(RFC5987 병기·한글 파일명 대응) + `X-Export-Truncated: true

Admin — Tickets (/api/v1/admin/tickets*)

CS 티켓 v1 (#027). OPERATOR-only. 목록 정렬 updatedAt desc.

MethodPathoperationIdAuth도입
GET/api/v1/admin/ticketslistTicketsOPERATOR#027 · #113 (티켓 목록 — TicketListResponse). query status·priority·assigneeOperatorId·hqId·storeId·category·page·size. status(OPEN/IN_PROGRESS/RESOLVED/CLOSEDpriority(URGENT/HIGH/NORMAL/LOWcategory(PLAYBACK/BROADCAST/BILLING/ACCOUNT/OTHERstoreId(UUID — 점장 store ticket 트리아지) 필터. 정렬 updatedAt desc. hqName·assigneeEmail·storeName·category nullable(#028·#113 — read-side LEFT JOIN store)
GET/api/v1/admin/tickets/{id}getTicketDetailOPERATOR#027 · #113 (티켓 상세 — TicketDetail, 코멘트 스레드 comments[] 포함). storeName·category nullable 노출(store ticket 식별). 미존재 시 404 TICKET_NOT_FOUND
POST/api/v1/admin/ticketscreateTicketOPERATOR#027 (티켓 생성 — body TicketCreateRequest { title, body, priority, hqId?, storeId? }TicketCreateResponse { id })
POST/api/v1/admin/tickets/{id}/commentsaddTicketCommentOPERATOR#027 (코멘트 추가 — body TicketCommentCreateRequest { kind: REPLY|INTERNAL, body }TicketCommentCreateResponse { id })
PATCH/api/v1/admin/tickets/{id}/statuschangeTicketStatusOPERATOR#027 (상태 전이 — body TicketStatusChangeRequest { status }. 잘못된 전이는 409 TICKET_INVALID_STATUS_TRANSITION)
PATCH/api/v1/admin/tickets/{id}/prioritychangeTicketPriorityOPERATOR#046 (우선순위 변경 — body TicketPriorityChangeRequest { priority }TicketPriorityChangeResponse { priority }). 상태머신 없어 자유 전이(409 없음), 이력 미기록. 동일값 멱등 200. 미존재 시 404 TICKET_NOT_FOUND
PATCH/api/v1/admin/tickets/{id}/assigneeassignTicketOPERATOR#027 (담당자 배정/해제 — body TicketAssignRequest { assigneeOperatorId? }, 미지정=해제)

Admin — Music (/api/v1/admin/music*)

음원 파일 업로드 v1 (#041) + 카탈로그 조회·소프트삭제 (#042). OPERATOR-only.

업로드/교체(#041) 는 multipart/form-data 요청 — 파일 파트 file(MP3) + 나머지 메타(title·durationSeconds)는 query parameter 로 전달한다. 서버가 업로드된 파일을 메모리에서 ID3v2.4 태그 재기록한 뒤 Azure Blob(또는 Local 어댑터)에 1회 저장하고 음원 row 를 생성한다. 임시 업로드·재업로드·다운로드 응답 없음. blob 파일명 UUID = 음원 PK(A안 — 식별자 통일). 파일 교체는 같은 key 덮어쓰기. 업로드/교체는 OperatorAuditLog 에 기록(MUSIC_UPLOADED·MUSIC_FILE_REPLACED).

조회/삭제(#042) 는 #041 이 연 음원 도메인 CRUD 를 닫는다. 목록은 created_at DESC, id DESC 고정 정렬 + 제목 부분검색(ILIKE)·페이지네이션, 활성(deleted_at IS NULL)만 노출한다(감사 #034 패턴 재사용). 삭제는 소프트삭제deleted_at 만 채우고 blob 은 유지(복구 가능·90일 보관 정합)하므로 조회·삭제는 storage 미의존(Azure 미설정에서도 동작). soft-deleted·미존재는 모두 404 MUSIC_NOT_FOUND (존재 은닉). 삭제는 OperatorAuditLogMUSIC_DELETED 로 기록.

MethodPathoperationIdAuth도입
GET/api/v1/admin/musiclistMusicOPERATOR#042·#059 (음원 목록 — query q?(제목 ILIKE, max 100)·musicSource?(AI/TRUST 타입 필터, #059)·page(=0)·size(=20, 1..100 clamp) → 200 MusicListResponse{ items, page, size, total }). 정렬 created_at DESC, id DESC 고정, 활성만. 항목은 MusicListItem(musicSource 포함·audioUrl 제외 — 재생 url 은 상세에서만). q 100자 초과 → 400
GET/api/v1/admin/music/:idgetMusicOPERATOR#042 (음원 상세 — path id200 MusicResponse, audioUrl 포함). soft-deleted·미존재 → 404 MUSIC_NOT_FOUND(존재 은닉)
DELETE/api/v1/admin/music/:iddeleteMusicOPERATOR#042 (음원 소프트삭제 — path id204 No Content, deleted_at 채움). blob 유지(hard-purge 후속). audit MUSIC_DELETED. 재삭제·soft-deleted·미존재 모두 → 404 MUSIC_NOT_FOUND(존재 은닉)
POST/api/v1/admin/musicuploadMusicOPERATOR#041·#059 (음원 업로드 — multipart 파트 file(MP3) + query title·durationSeconds(클라 추출)·musicSource(AI/TRUST, 필수·불변, #059) → 201 MusicResponse). 서버가 ID3v2.4 태그 재기록 후 blob 1회 저장 + row insert. 비-MP3/손상 ID3 → 400 METADATA_PARSE_FAILED · title 누락·빈 파일·20MB 초과·musicSource 누락/오값 → 400 MUSIC_INVALID_FIELD
PUT/api/v1/admin/music/:id/filereplaceMusicFileOPERATOR#041 (음원 파일 교체 — multipart 파트 file(MP3) + query title?(미제공 시 기존 유지) → 200 MusicResponse). 같은 blob key 덮어쓰기(파일 이력 없음 — audit row 로 보존). 음원 미존재 → 404 MUSIC_NOT_FOUND · 비-MP3/손상 → 400 METADATA_PARSE_FAILED · 빈 파일·20MB 초과 → 400 MUSIC_INVALID_FIELD

음원 메타 추출 endpoint 는 두지 않는다title·durationSeconds클라이언트가 파일에서 추출해 query 로 전달한다. ID3 태그 셋(TIT2·TPE1/TALB/TPE2="Linkmusic"·TDRC·TXXX:linkmusic· APIC)·식별자 통일·교체 덮어쓰기·카탈로그 조회·소프트삭제 상세는 Music · Data Schema 참조.

Admin — Music Tag Options (/api/v1/admin/music-tag-options*)

장르·무드 태그 옵션 도메인(#132) — 라이브러리·플레이리스트 분류에 쓰이는 GENRE/MOOD 옵션 CRUD. OPERATOR-only. 목록은 페이지네이션 없음(타입별 옵션 수 소규모)·sort_order ASC → value ASC 고정 정렬· 비활성(active=false) 옵션도 함께 노출(운영자 관리 화면). 삭제는 soft delete(active=false, 멱등) — 기존 음원이 그 값을 참조 중이어도 안전. type 은 생성 후 불변(수정 불가). 같은 타입 내 value unique.

MethodPathoperationIdRole비고
GET/api/v1/admin/music-tag-optionslistMusicTagOptionsOPERATOR#132 (옵션 목록 — query type(GENRE/MOOD, 필수) → 200 MusicTagOptionListResponse{ items }). 정렬 sort_order ASC, value ASC 고정·페이지네이션 없음·비활성 포함. 항목 MusicTagOptionResponse
POST/api/v1/admin/music-tag-optionscreateMusicTagOptionOPERATOR#132 (옵션 생성 — { type, value(1..50), sortOrder?(미지정 0) }201 + Location, MusicTagOptionResponse). 생성 시 active=true. value 빈값/51자 → 400 · 같은 타입 내 동일 value 중복 → 409 MUSIC_TAG_OPTION_DUPLICATE
PATCH/api/v1/admin/music-tag-options/:idupdateMusicTagOptionOPERATOR#132 (옵션 부분 수정 — { value?(1..50), sortOrder?, active? }, 셋 다 null 이면 no-op → 200 MusicTagOptionResponse). type 불변. 미존재 → 404 MUSIC_TAG_OPTION_NOT_FOUND · value 변경 시 중복 → 409 MUSIC_TAG_OPTION_DUPLICATE
DELETE/api/v1/admin/music-tag-options/:iddeleteMusicTagOptionOPERATOR#132 (옵션 soft delete — path id204 No Content, active=false). 이미 비활성이어도 멱등 204. 음원 참조 보존(안전). 미존재 → 404 MUSIC_TAG_OPTION_NOT_FOUND

운영사 UI(/settings/music-options 2섹션 CRUD·inline 에러·빈 상태 CTA)는 설정 18-3 · 테이블 스키마는 Data Schema 참조. 음원 업로드 폼 연동(드롭다운)은 후속.

Admin — Library (/api/v1/admin/libraries*)

라이브러리 도메인(#053) — 음원→라이브러리 2계층의 중간 층. 라이브러리 = 타입(AI/TRUST) 묶음의 음원 컬렉션(OPERATOR 소유). CRUD + 음원 할당/해제(M:N). OPERATOR-only. 라이브러리 목록은 created_at DESC 고정 정렬 + 이름 부분검색(ILIKE)·타입 필터·페이지네이션, 활성(deleted_at IS NULL)만 노출. 삭제는 소프트삭제(담긴 음원 할당 유지·복구 가능). 음원 추가/제거는 멱등(unique(library,music)). soft-deleted·미존재 라이브러리는 404 LIBRARY_NOT_FOUND(존재 은닉).

MethodPathoperationIdAuth도입
GET/api/v1/admin/librarieslistLibrariesOPERATOR#053 (라이브러리 목록 — query q?(이름 ILIKE, max 100)·libraryType?(AI/TRUST)·page(=0)·size(=20, 1..100 clamp) → 200 LibraryListResponse{ items, page, size, total }). 정렬 created_at DESC 고정, 활성만. 항목 LibraryListItem(곡 수 제외 — 곡 수는 상세에서만)
POST/api/v1/admin/librariescreateLibraryOPERATOR#053 (라이브러리 생성 — {name(1..255), libraryType(AI/TRUST)}201 LibraryResponse, 곡 수 0). 타입은 생성 시 고정(이후 불변). 이름·타입 위반 → 400 LIBRARY_INVALID_FIELD
GET/api/v1/admin/libraries/:idgetLibraryOPERATOR#053 (라이브러리 상세 — path id200 LibraryResponse, musicCount 포함). soft-deleted·미존재 → 404 LIBRARY_NOT_FOUND
PATCH/api/v1/admin/libraries/:idupdateLibraryOPERATOR#053 (이름 수정 — {name}(libraryType 불변) → 200 LibraryResponse). soft-deleted·미존재 → 404 LIBRARY_NOT_FOUND
DELETE/api/v1/admin/libraries/:iddeleteLibraryOPERATOR#053 (라이브러리 소프트삭제 — path id204 No Content, deleted_at 채움). 담긴 음원 할당 유지(복구 가능). 재삭제·soft-deleted·미존재 모두 → 404 LIBRARY_NOT_FOUND(존재 은닉)
GET/api/v1/admin/libraries/:id/musiclistLibraryMusicOPERATOR#053 (라이브러리 곡 목록 — query page(=0)·size(=20, 1..100 clamp) → 200 LibraryMusicListResponse{ items, page, size, total }). 정렬 할당 시각 DESC, 항목 LibraryMusicListItem(audioUrl 제외). 라이브러리 미존재 → 404 LIBRARY_NOT_FOUND
POST/api/v1/admin/libraries/:id/musicaddLibraryMusicOPERATOR#053·#059 (음원 일괄 추가 — {musicIds[](0..200)}204 No Content, 멱등 — 이미 담긴 음원 무시). 라이브러리 미존재 → 404 LIBRARY_NOT_FOUND · 음원 1건이라도 미존재 → 404 MUSIC_NOT_FOUND(all-or-nothing) · 음원 타입(musicSource) ≠ 라이브러리 타입(libraryType) → 400 LIBRARY_TYPE_MISMATCH(위반 musicId 를 fields.violatingMusicIds 에 노출·전체 reject, #059)
DELETE/api/v1/admin/libraries/:id/music/:musicIdremoveLibraryMusicOPERATOR#053 (음원 제거 — path id·musicId204 No Content, 멱등 — 담겨 있지 않아도 성공). 음원 자체는 유지(할당만 해제). 라이브러리 미존재 → 404 LIBRARY_NOT_FOUND

라이브러리는 단일 타입 묶음(AI/TRUST 혼합 불가). 음원 타입 enforcement(“라이브러리 타입 = 음원 타입”)는 음원 enrich 후 후속(#053 F1). 운영사 UI(목록·생성·상세 곡 목록·카탈로그 [라이브러리에 추가]) 상세는 Library · DTO 는 DTOs · Library 참조.

Admin — Playlist (/api/v1/admin/playlists*)

플레이리스트 도메인(#054) — 음원→라이브러리→플레이리스트 2계층의 최상위 층. 플레이리스트 = 라이브러리 묶음(OPERATOR 소유, hqId 본사별). 곡 직접 안 담음 — 라이브러리 단위. CRUD + 라이브러리 담기/제거/순서. OPERATOR-only. 플레이리스트 목록은 created_at DESC 고정 정렬 + 이름 부분검색(ILIKE)· 본사 필터·페이지네이션, 활성(deleted_at IS NULL)만 노출. 담긴 라이브러리 목록은 position ASC. 삭제는 소프트삭제. 라이브러리 담기/제거는 멱등(unique(playlist,library)). soft-deleted·미존재 플레이리스트는 404 PLAYLIST_NOT_FOUND(존재 은닉).

MethodPathoperationIdAuth도입
GET/api/v1/admin/playlistslistPlaylistsOPERATOR#054 (플레이리스트 목록 — query q?(이름 ILIKE, max 100)·hqId?(소유 본사)·page(=0)·size(=20, 1..100 clamp) → 200 PlaylistListResponse{ items, page, size, total }). 정렬 created_at DESC 고정, 활성만. 항목 PlaylistListItem(라이브러리 수 제외 — 상세에서만)
POST/api/v1/admin/playlistscreatePlaylistOPERATOR#054 (플레이리스트 생성 — {hqId, name(1..255)}201 PlaylistResponse, 라이브러리 수 0). hqId 는 생성 시 고정(이후 불변). 본사 미존재 → 404 HQ_NOT_FOUND · 이름 위반 → 400 PLAYLIST_INVALID_FIELD
GET/api/v1/admin/playlists/:idgetPlaylistOPERATOR#054 (플레이리스트 상세 — path id200 PlaylistResponse, libraryCount 포함). soft-deleted·미존재 → 404 PLAYLIST_NOT_FOUND
PATCH/api/v1/admin/playlists/:idupdatePlaylistOPERATOR#054 (이름 수정 — {name}(hqId 불변) → 200 PlaylistResponse). soft-deleted·미존재 → 404 PLAYLIST_NOT_FOUND
DELETE/api/v1/admin/playlists/:iddeletePlaylistOPERATOR#054 (플레이리스트 소프트삭제 — path id204 No Content, deleted_at 채움). 재삭제·soft-deleted·미존재 모두 → 404 PLAYLIST_NOT_FOUND(존재 은닉)
GET/api/v1/admin/playlists/:id/librarieslistPlaylistLibrariesOPERATOR#054 (담긴 라이브러리 목록 — path id200 PlaylistLibraryListResponse{ items, total }). 정렬 position ASC, 항목 PlaylistLibraryListItem. 플레이리스트 미존재 → 404 PLAYLIST_NOT_FOUND
POST/api/v1/admin/playlists/:id/librariesaddPlaylistLibraryOPERATOR#054 (라이브러리 담기 — {libraryId}200/204, 멱등 — 이미 담긴 라이브러리 무시, 끝에 append). 플레이리스트 미존재 → 404 PLAYLIST_NOT_FOUND · 라이브러리 미존재 → 404 LIBRARY_NOT_FOUND
DELETE/api/v1/admin/playlists/:id/libraries/:libraryIdremovePlaylistLibraryOPERATOR#054 (라이브러리 제거 — path id·libraryId204 No Content, 멱등 — 담겨 있지 않아도 성공). 라이브러리 자체는 유지(담기만 해제). 플레이리스트 미존재 → 404 PLAYLIST_NOT_FOUND
PUT/api/v1/admin/playlists/:id/libraries/orderreorderPlaylistLibrariesOPERATOR#054 (라이브러리 순서 변경 — {libraryIds[](0..500)}, 현재 담긴 라이브러리와 정확히 일치하는 전체 순서 → 200). 플레이리스트 미존재 → 404 PLAYLIST_NOT_FOUND
PUT/api/v1/admin/playlists/:id/defaultsetDefaultPlaylistOPERATOR#058 (본사 기본 PL 지정 — 요청 본문 없음 → 200 PlaylistResponse, isDefault=true). 본사당 1개 — 같은 본사 기존 기본 PL 을 같은 트랜잭션에서 원자적 해제(partial unique index). soft-deleted·미존재 → 404 PLAYLIST_NOT_FOUND
DELETE/api/v1/admin/playlists/:id/defaultclearDefaultPlaylistOPERATOR#058 (본사 기본 PL 해제 — 요청 본문 없음 → 204 No Content, 멱등 — 이미 비기본·미존재여도 성공). 해제 후 그 본사에 기본 PL 이 없으면 점장 큐는 활성 PL 부재 시 fallback 대상이 없어 NO_ACTIVE_PLAYLIST

플레이리스트는 라이브러리 묶음(곡 직접 아님 — 라이브러리 경유). 기본 PL(#058) — 운영사가 본사당 1개를 기본으로 지정/해제하며(is_default), 점장 큐(#056)는 매장 활성 PL 부재 시 그 본사 기본 PL 로 fallback 한다(응답 source=DEFAULT). status 5종 파생(active/unused/empty/fallback/mismatch)은 후속(#054 F2~F4). 운영사 UI(목록·생성·상세 라이브러리 편집·기본 지정 토글) 상세는 Playlist · DTO 는 DTOs · Playlist 참조.

HQ Mode — 본사 본인 (/api/v1/hq/*)

본사(HQ_MANAGER) 본인용 endpoint (#049 read · #085 본인 매니저 이름 편집). /api/v1/hq/** prefix 매처 → hasRole("HQ_MANAGER") (SecurityConfig D1)가 1차 인가 경계. service 가 PrincipalScopeGuard 로 claim↔DB 재검증한다.

MethodPathoperationIdAuth도입
GET/api/v1/hq/megetHqMeHQ_MANAGER#049 — 본인 본사 요약(HqMeResponse — 본사명·유형·요금제·상태·사업자번호·청구 기준일·매장 수·생성 시각). 토큰 claim 을 DB(OperatorAccount)로 재검증 — 비활성(SUSPENDED·WITHDRAWN)·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 본사 데이터 미존재 404. generated 훅 useGetHqMe·getGetHqMeQueryKey(["/api/v1/hq/me"])
PATCH/api/v1/hq/meupdateHqMeHQ_MANAGER#085 — 본인 매니저 계정 편집 (UpdateHqMeRequest { name?: string | null } — 본인 매니저 본인 이름. null/생략 = 미변경, non-null 이면 1~50자). 응답 HqMeResponse(GET 과 동일 DTO 재사용). email 변경 금지·비밀번호 변경 금지(별도 endpoint, /onboarding/change-password SPEC #003). 본사명·다중 매니저 관리는 운영자 영역(F1·F4 후속). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 빈/blank name 400. generated 훅 useUpdateHqMe. apps/space /admin/settings 본인 매니저 이름 편집 form.
PATCH/api/v1/hq/me/duckingupdateHqDuckingHQ_MANAGER본사 더킹 default 편집. body UpdateHqDuckingRequest{duckEnabled·duckVolumePercent(0~100)·duckFadeMs(0~5000)} 3필드 모두 필수 = 전체 set(부분 PATCH 아님, 전체 replace) — 본사 default 는 항상 값이 존재하므로 한 필드만 바꿔도 세 값을 전송. 멘트(안내방송·CM) 중 배경음악을 정지하지 않고 볼륨만 감쇠해 동시 재생하는 동작의 산하 매장 기본값. 매장별 override 는 매장 상세(updateStoreDucking). 응답 200 HqMeResponse(GET 동일 DTO — duck* read-back). CM 사이클 빈도(#095)와 같은 본사 default + 매장 override 위계. 범위 밖 400 · claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useUpdateHqDucking. apps/space /admin/settings 더킹 default 섹션(사용 토글 + 볼륨% + fade ms + [저장]).
GET/api/v1/hq/dashboardgetHqDashboardHQ_MANAGER#051 — 본인 본사 산하 매장 상태 요약(HqDashboardResponse — 총수·ACTIVE·INACTIVE·SUSPENDED·폐점수). hqId 는 토큰 주체에서 도출(요청 파라미터 없음, 타 본사 미집계). 송출·정산 지표는 도메인 미도착이라 미포함(후속). claim↔DB 재검증 — 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqDashboard·getGetHqDashboardQueryKey(["/api/v1/hq/dashboard"]). apps/space /admin 대시보드 카드.
GET/api/v1/hq/dashboard/undelivered-todaygetHqUndeliveredTodayHQ_MANAGER#119 F3·D4 — 오늘(KST) 송출 미도달 매장 집계. 200 HqUndeliveredTodayResponse{undeliveredStoreCount} = 오늘(KST 윈도우) 생성된 안내방송 dispatch 중 status=PENDING 인 distinct store_id 수. dispatch 는 미ack 시 영구 PENDING 이라 전체 카운트는 노이즈 누적 → today(KST)로 한정(D4). hqId 토큰 주체 도출(타 본사 미집계). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqUndeliveredToday·getGetHqUndeliveredTodayQueryKey. apps/space /admin 대시보드 “오늘 송출 미도달 매장” 카드(60초 폴링·count>0 danger 강조).
GET/api/v1/hq/storeslistHqStoresHQ_MANAGER#051 — 본인 본사 산하 매장 목록(HqStoreListResponse{items,page,size,total}). query q?(매장명·주소 부분일치, 대소문자 무시, ≤100)·status?(ListHqStoresStatustype?(ListHqStoresTypepage(0-base)·size(1..100 clamp). 정렬 status 우선순위(SUSPENDED·INACTIVE 우선)→name asc→id asc. hqId 는 토큰 주체 도출(타 본사 미노출). claim↔DB 재검증 — 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqStores·getListHqStoresQueryKey. apps/space /admin/stores 목록.
GET/api/v1/hq/stores/{id}getHqStoreHQ_MANAGER#084 — 본인 본사 산하 매장 단건 상세 (HqStoreDetailResponse — 개요(id·name·address·phone·type·status·closedAt) + 점장 정보(storeManager: HqStoreManagerInfo?) + 활성 PL(activePlaylist: HqStoreActivePlaylistInfo?) + 시각). 매장.hqId ≠ 주체 hqId 또는 미존재 → 404 STORE_NOT_FOUND(타 본사 매장 존재 은닉). hqId 는 토큰 주체 도출. 편집은 SPEC #105 의 PATCH endpoint 도입(정지/복구/폐점·점장 발급/관리·활성 PL 적용/해제는 여전히 운영사 /api/v1/admin/stores/** 전용). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqStore·getGetHqStoreQueryKey(["/api/v1/hq/stores/{id}"]). apps/space /admin/stores/[id] 5 섹션(개요/점장 정보/활성 PL/운영 상태/메타).
PATCH/api/v1/hq/stores/{id}updateHqStoreHQ_MANAGER#105 — 본사 산하 매장 마스터 정보 편집(#084 F1 마감). body UpdateHqStoreRequest{name?·address?·managerName?·managerEmail?·managerPhone?} 모두 JsonNullable<String>키 부재 = 미변경 / 키 + null = clear / 키 + value = set (단 name 은 비-nullable 컬럼이라 null clear 불가, 명시 null 시 400). 응답 200 HqStoreDetailResponse (read-back — FE invalidate 부담 최소화, D7). 검증 D8: name 1..50 비-blank · address ≤200 · managerName ≤50 · managerEmail @Email + ≤255 · managerPhone ≤30 free-form. 위반 시 400 HQ_STORE_INVALID_FIELD (jakarta validation 표준이 JsonNullable 안의 값을 unwrap 하지 않아 service 가 명시 검증). 매장.hqId ≠ 주체 또는 미존재 → 404 STORE_NOT_FOUND(존재 은닉, D6). 변경 0 = no-op 200(audit row 도 생성 X). 변경된 필드만 hq_audit_log 에 1행 기록 — HqAuditAction.HQ_STORE_UPDATED + HqAuditTargetType.STORE, detail.changedFields 키 배열 + before/after partial 스냅샷(개인정보 최소 노출). impersonation 컨텍스트(운영자가 본사 위장)는 actorRole=OPERATOR_IMPERSONATING + impersonatedByEmail 동시 기록. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useUpdateHqStore. apps/space /admin/stores/[id] 매장 정보 섹션 [편집] 토글 인라인 폼(5 input, read ↔ edit 모드). 후속(#084 라인): F2 매장 등록 · F3 CSV 일괄 등록 · F4 상태 전이 본사 위임 · F5 점장 계정 발급 본사 위임.
PATCH/api/v1/hq/stores/{id}/duckingupdateStoreDuckingHQ_MANAGER매장 더킹 override 편집(CM 사이클 override #103 미러). body UpdateStoreDuckingRequest{duckEnabled?·duckVolumePercent?(0~100)·duckFadeMs?(0~5000)}per-field null=override 제거(HQ default 복귀), non-null=override 적용. 전체 replace 의미라 세 필드를 항상 함께 전송(부분 PATCH 아님). “상속” 모드 = 세 필드 모두 null, “override” 모드 = 세 필드 모두 값. 현재 override / HQ default 참조는 HqStoreDetailResponseduck*/hqDuck*. 응답 204 — FE 매장 상세 invalidate(effective 가 점장 player me 로 read-back). 본사 격리 WHERE store.hq_id=:hqId AND store.id=:id — 타 본사 매장 404 STORE_NOT_FOUND 은닉. 범위 밖 400 · claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useUpdateStoreDucking. apps/space /admin/stores/[id] 더킹 override 섹션(라디오 상속/override + 사용 토글 + 볼륨% + fade ms + [저장]).
PATCH/api/v1/hq/stores/{id}/regionupdateStoreRegionHQ_MANAGER#144 — 본사 산하 매장 지역(시/도) 편집(REGION 모드 송출의 그룹핑 축). body UpdateStoreRegionRequest{region?: Region}Region 17 시/도 enum nullable. 키 + value = set / 키 + null = clear(미지정). 미지정 매장은 지역 송출에 포함되지 않는다. 현재 region 참조는 HqStoreDetailResponse.region. 응답 204 — FE 매장 상세 invalidate. 본사 격리 WHERE store.hq_id=:hqId AND store.id=:id — 타 본사 매장 404 STORE_NOT_FOUND 은닉. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. 같은 트랜잭션에 audit HqAuditAction.HQ_STORE_REGION_UPDATED(총 15종) + HqAuditTargetType.STORE(before/after region 스냅샷). generated 훅 useUpdateStoreRegion. apps/space /admin/stores/[id] 매장 지역 섹션(시/도 select + [저장], “미지정” 옵션 = null clear, 변경 0 시 disabled).
POST/api/v1/hq/storescreateHqStoreHQ_MANAGER#106 — 본사 산하 매장 신규 등록(#084 F2 마감). body CreateHqStoreRequest{name·address?·managerName?·managerEmail?·managerPhone?} 단순 nullable(#105 와 달리 JsonNullable 아님 — 신규 등록이라 clear vs unchanged 분기 불필요, null/생략 = 미입력). type 은 본사 유형 자동 결정(D2 — INDEPENDENT 가상 본사 산하 → INDEPENDENT, 그 외 → FRANCHISE. DIRECT 는 운영자 직영 표시라 본사 자체 결정 X — body 에 type 필드 없음). 점장(STORE_MANAGER) 계정 발급은 분리(D3 — 운영자 또는 후속 F5). 검증 D4: name 1..50 비-blank 필수 · address ≤200 · managerName ≤50 · managerEmail @Email + ≤255 · managerPhone ≤30 free-form. 위반 시 400 HQ_STORE_INVALID_FIELD. 정지(SUSPENDED) 본사는 등록 거부 → 403 AUTH_HQ_SUSPENDED(login 차단이 first line, service 가드 second line — D5). 동일 본사 내 name 중복 허용(분점 패턴, D6). 응답 201 HqStoreDetailResponse(read-back — FE 가 새 매장 상세 페이지로 직행하며 추가 fetch 0, D1). audit HqAuditAction.HQ_STORE_CREATED 신규(총 9종) + HqAuditTargetType.STORE 재사용, detail = {name, type, address, managerName} partial 스냅샷(D7). hqId 는 토큰 주체 도출. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useCreateHqStore. apps/space /admin/stores/new 단일 단계 폼(5 input + [취소]/[등록]) — 매장 목록 헤더 [+ 매장 등록] 진입. 잔여(#084 라인): F4 상태 전이 본사 위임 · F5 점장 계정 발급 본사 위임.
POST/api/v1/hq/stores/bulkbulkCreateHqStoresHQ_MANAGER#111 — 본사 산하 매장 CSV 일괄 등록(#084 F3 마감, MVP). consumes multipart/form-data, @RequestPart("file") MultipartFile (CSV). #106 단일 등록을 행 단위로 미러하되 MVP 는 매장 마스터 5필드만(점장 계정 발급·영업시간 제외 — 후속). CSV 헤더 5컬럼 정확 일치(순서·이름): 매장명,주소,담당자명,담당자이메일,담당자전화 (UTF-8, BOM 제거, RFC4180). 부분 실패 = 행별 독립 처리 + 성공분 커밋 + 결과 리포트(D2 — 한 행 실패가 전체 롤백 X). 행 검증 = #106 미러(name 1..50 비-blank · address ≤200 · managerName ≤50 · managerEmail @Email+≤255 · managerPhone ≤30). type 자동 결정(D5 — INDEPENDENT 산하→INDEPENDENT/그외→FRANCHISE). 중복 매장명 무조건 새 생성(D3 분점 패턴). audit 행별 HQ_STORE_CREATED(성공 행마다 1건, D6). 응답 200 BulkCreateHqStoreResponse{totalRows, successCount, failureCount, results: BulkRowResult[]} — 행별 {rowNumber(1-base, 헤더 제외), status(SUCCESS|FAILED), storeId?, storeName?, errorCode?, errorMessage?}. 파일 자체 오류(헤더 불일치·빈 파일·파싱 불가)는 행 처리 전 400 HQ_STORE_BULK_INVALID_FILE(행 검증 실패와 구분). 정지(SUSPENDED) 본사는 파일 처리 전 가드 → 403 AUTH_HQ_SUSPENDED(D5). hqId 는 토큰 주체 도출(verifyHqScope). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useBulkCreateHqStores(multipart mutator — 음원 업로드 #041 선례 미러, FormData file 파트). apps/space /admin/stores/bulk 파일 업로드 + 결과 리포트(요약 + 실패 행 테이블) — 매장 목록 헤더 [CSV 일괄 등록] 진입. atom-grounded 임시(시안 부재 — design-debt §2).
GET/api/v1/hq/playlistslistHqPlaylistsHQ_MANAGER#057 — 본인 본사 플레이리스트 목록(HqPlaylistListResponse{items,page,size,total}, 행=HqPlaylistListItem 이름·라이브러리 수·적용 매장 수·updatedAt). query q?(이름 부분일치, 대소문자 무시, ≤100)·page(0-base)·size(1..100 clamp). 정렬 created_at DESC, id DESC 서버 고정. 적용 매장 수 = 이 PL 을 활성으로 쓰는 본인 본사 매장 수. hqId 는 토큰 주체 도출(요청 파라미터 없음, 타 본사 PL 비노출 — verifyHqScope). read-only(생성/편집/삭제·적용은 운영사 /api/v1/admin/playlists 전용). claim↔DB 재검증 — 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqPlaylists·getListHqPlaylistsQueryKey. apps/space /admin/playlists 목록.
GET/api/v1/hq/playlists/{id}getHqPlaylistHQ_MANAGER#057 — 본인 본사 플레이리스트 단건 상세(HqPlaylistDetailResponse — 개요 + 라이브러리 수·적용 매장 수 + libraries: HqPlaylistLibraryItem[] position 순). PL.hqId ≠ 주체 hqId 또는 미존재 → 404 PLAYLIST_NOT_FOUND(타 본사 PL 존재 은닉). hqId 는 토큰 주체 도출. read-only. claim↔DB 재검증 — 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqPlaylist·getGetHqPlaylistQueryKey(["/api/v1/hq/playlists/{id}"]). apps/space /admin/playlists/[id] 상세(server fetch, 404→notFound).
GET/api/v1/hq/librarieslistHqLibrariesHQ_MANAGER#080 — 본사가 볼 수 있는 라이브러리 목록(HqLibraryListResponse{items,page,size,total}, 행=HqLibraryListItem 이름·타입(AI/TRUST)·trackCount(소속 음원 수)·playlistCount(본인 본사 PL 사용 수)·created/updatedAt). query q?(이름 부분일치, 대소문자 무시, ≤100)·type?(ListHqLibrariesType = AI/TRUST)·page(0-base)·size(1..100 clamp). 정렬 name ASC 서버 고정. 가시 범위: 운영사가 만든 모든 라이브러리(본사가 자기 PL 에 담을 후보 — 본사 PL #057 와 동일 패턴, plan/type mismatch 게이트는 후속 #060 F1). read-only(생성/편집/삭제·음원 할당은 운영사 /api/v1/admin/libraries 전용). claim↔DB 재검증 — 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqLibraries·getListHqLibrariesQueryKey. apps/space /admin/libraries 목록.
GET/api/v1/hq/libraries/{id}getHqLibraryHQ_MANAGER#107 — 본사 라이브러리 단건 상세 메타(HqLibraryDetailResponse{id·name·type(AI/TRUST)·trackCount·playlistCount·createdAt·updatedAt}). trackCount=소속 활성 음원 수 · playlistCount=이 라이브러리를 쓰는 본인 본사 활성 PL 수. 라이브러리는 운영사 공유 모델(hqId 컬럼 없음) — verifyHqScope(403 가드)만 통과하면 모든 활성 라이브러리 조회 가능(#080 §D3). 미존재/soft-deleted → 404 LIBRARY_NOT_FOUND(PL 상세 #057 의 hqId 은닉 404 분기 없음). read-only. claim↔DB 재검증 — 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqLibrary·getGetHqLibraryQueryKey(["/api/v1/hq/libraries/{id}"]). apps/space /admin/libraries/[id] 상세(server fetch, 404→notFound).
GET/api/v1/hq/libraries/{id}/musiclistHqLibraryMusicHQ_MANAGER#107 — 본사 라이브러리 트랙(담긴 음원) 목록(HqLibraryMusicListResponse{items,page,size,total}, 행=HqLibraryMusicListItem id·title·durationSeconds·created/updatedAt). query page(0-base)·size(1..100 clamp, 기본 20). 정렬 lm.createdAt DESC, m.id DESC(할당 시각순) 서버 고정. audioUrl·타입 배지 미포함(라이브러리 단일 타입). 라이브러리 미존재/soft-deleted → 404 LIBRARY_NOT_FOUND. read-only(음원 추가/제거는 운영사 전용). claim↔DB 재검증 — 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqLibraryMusic·getListHqLibraryMusicQueryKey. apps/space /admin/libraries/[id] 트랙 테이블(client query, 페이지네이션).
POST/api/v1/hq/announcementscreateHqTtsAnnouncementHQ_MANAGER#061·#063 — TTS 안내방송 생성(합성). body CreateTtsAnnouncementRequest{title(≤255)·text(NotBlank,≤1000)·voice(TtsVoice)·tonePreset?(TtsTonePreset, @nullable, 기본 NORMAL)} → Typecast 합성(동기 완료)→Azure blob 저장→201 TtsAnnouncementDetailResponse. 외부 실패 502 TTS_SYNTHESIS_FAILED · 토큰 미설정 503 TTS_TOKEN_NOT_CONFIGURED(5xx 격리) · voice 미지원 톤 400 TTS_INVALID_TONE_PRESET. hqId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useCreateHqTtsAnnouncement. apps/space /admin/announcements 생성 다이얼로그(voice 연동 톤 select, pending=“합성 중…”).
GET/api/v1/hq/tts-voiceslistHqTtsVoicesHQ_MANAGER#063 — voice별 허용 톤 프리셋 매핑 조회. 200 TtsVoiceListResponse{voices:[TtsVoiceOption{voice·voiceDisplayName·presets:[TtsTonePresetOption{preset·presetDisplayName}]}]}(5종, 각 voice 항상 NORMAL 포함). claim↔DB 재검증 403 · 미인증 401. generated 훅 useListHqTtsVoices·getListHqTtsVoicesQueryKey(["/api/v1/hq/tts-voices"]). apps/space 생성·수정 다이얼로그가 톤 select 옵션을 voice 에 연동하는 데 사용.
GET/api/v1/hq/announcementslistHqTtsAnnouncementsHQ_MANAGER#061·#063 — 본인 본사 안내방송 목록(TtsAnnouncementListResponse{items,page,size,total}, 행=TtsAnnouncementListItem title·voice·voiceDisplayName·tonePreset·tonePresetDisplayName·durationSeconds?·createdAt). query q?(제목 부분일치, ≤100)·page(0-base)·size(1..100 clamp). 정렬 created_at DESC 서버 고정, soft-delete 제외. hqId 토큰 주체 도출(타 본사 비노출 — verifyHqScope). list item 에 audioUrl 미포함(상세에만). claim↔DB 재검증 403 · 미인증 401. generated 훅 useListHqTtsAnnouncements·getListHqTtsAnnouncementsQueryKey. apps/space /admin/announcements 목록(비-NORMAL 톤 배지).
GET/api/v1/hq/announcements/{id}getHqTtsAnnouncementHQ_MANAGER#061·#063 — 안내방송 단건 상세(TtsAnnouncementDetailResponse — text·voice·voiceDisplayName·tonePreset·tonePresetDisplayName·audioUrl·durationSeconds?·created/updatedAt). hqId ≠ 주체 또는 미존재 → 404 TTS_ANNOUNCEMENT_NOT_FOUND(존재 은닉). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqTtsAnnouncement·getGetHqTtsAnnouncementQueryKey(["/api/v1/hq/announcements/{id}"]). FE 는 목록 행 [재생] 시 lazy fetch 해 <audio src={audioUrl}> 재생, 수정 다이얼로그 초기값(톤 포함) 채움.
PUT/api/v1/hq/announcements/{id}updateHqTtsAnnouncementHQ_MANAGER#062·#063 — 안내방송 수정(조건부 재합성). body UpdateTtsAnnouncementRequest{title(≤255)·text(≤1000)·voice(TtsVoice)·tonePreset?(TtsTonePreset, @nullable)} 전체 교체 → 200 TtsAnnouncementDetailResponse. text·voice·tonePreset 중 하나라도 기존과 다르면 Typecast 재합성(같은 blob url 덮어쓰기 + durationSeconds 갱신), title 만 변경되면 재합성 skip(§5-1). 재합성 실패 502 TTS_SYNTHESIS_FAILED · 토큰 미설정 503 TTS_TOKEN_NOT_CONFIGURED — 둘 다 row 미변경(원자성) · voice 미지원 톤 400 TTS_INVALID_TONE_PRESET. hqId ≠ 주체/미존재/삭제 → 404 TTS_ANNOUNCEMENT_NOT_FOUND(존재 은닉). updatedAt 갱신. claim↔DB 재검증 403 · 미인증 401. generated 훅 useUpdateHqTtsAnnouncement. apps/space /admin/announcements 행 [수정] → create/edit 겸용 다이얼로그(상세 fetch 로 초기값 채움, voice 연동 톤 select, pending=“합성 중…”). 재생 캐시버스터 ?v={updatedAt}.
DELETE/api/v1/hq/announcements/{id}deleteHqTtsAnnouncementHQ_MANAGER#061 — 안내방송 삭제(soft-delete), 204. UPDATE ... WHERE id AND hq_id AND deleted_at IS NULL(affected=0 → 404 TTS_ANNOUNCEMENT_NOT_FOUND). blob 유지(복구 가능). claim↔DB 재검증 403 · 미인증 401. generated 훅 useDeleteHqTtsAnnouncement. apps/space 행 삭제 확인 다이얼로그(404=이미 삭제됨 흡수).
POST/api/v1/hq/announcements/{id}/dispatchdispatchHqTtsAnnouncementHQ_MANAGER송출 슬라이스 — 안내방송 송출(매장 fan-out). body DispatchRequest{target(DispatchRequestTarget ALL/STORES)·storeIds?(STORES 일 때 필수, 전달 시 1~1000개, 모두 본인 본사 산하)·scheduledAt?(SPEC #078, ISO-8601 nullable — null=즉시 송출(기본 PENDING fan-out), non-null=예약 송출(SCHEDULED row 적재 후 백그라운드 디스패처가 도래 시 PENDING 전이))} → 매장당 1개 PENDING(즉시) 또는 SCHEDULED(예약) announcement_dispatch row 생성 → 201 DispatchResponse{dispatchedCount, dispatchIds}. target=ALL=산하 매장 전체(폐점 제외·0건이면 dispatchedCount=0), STORES=지정 매장(빈 배열 → 400 검증(minItems:1) / storeIds 생략(null) → 400 DISPATCH_INVALID_TARGET / 미존재·타 본사 혼입 → 404 TTS_ANNOUNCEMENT_NOT_FOUND 전체 은닉). 중복 송출 허용(PENDING 누적). scheduledAt 검증(SPEC #078): <= now → 400 DISPATCH_SCHEDULED_AT_PAST(과거 차단) · > now + 1year → 400 DISPATCH_SCHEDULED_AT_TOO_FAR(1년 상한, 페이로드 폭주 가드). hqId ≠ 주체/미존재 안내방송 → 404 TTS_ANNOUNCEMENT_NOT_FOUND. claim↔DB 재검증 403 · 미인증 401. rate limit DISPATCH 30/분 — 초과 시 429 RATE_LIMITED + Retry-After(error-codes). generated 훅 useDispatchHqTtsAnnouncement. apps/space /admin/announcements 행 [송출] 확인 다이얼로그(SPEC #072 ALL/STORES 모드 토글 + SPEC #078 즉시/예약 모드 토글 + date/time 입력 + 결과 배너 분기).
GET/api/v1/hq/announcements/{id}/dispatcheslistHqTtsAnnouncementDispatchesHQ_MANAGER#065 — 안내방송 송출 이력 조회(read-only). 200 DispatchHistoryResponse{items,page,size,total,aggregate}(행=DispatchHistoryItem{dispatchId·storeId·storeName·status(DispatchHistoryItemStatus SCHEDULED/PENDING/PLAYED/CANCELED/MISSED — #077·#078·#143 5종)·createdAt·playedAt?·scheduledAt?}, aggregate=DispatchHistoryAggregate{total,played,pending,scheduled,distinctStoreCount} 페이지 무관 전체 카운트). query page?(0-base)·size?(1..100 clamp). 정렬 created_at DESC, id DESC 서버 고정(결정적). 중복 송출 허용 — 같은 매장에 여러 번 송출된 경우 row 단위로 전부 노출(매장 그룹핑 요약은 후속 F). hqId ≠ 주체/미존재/삭제/STORE_BROADCAST 출처 → 404 TTS_ANNOUNCEMENT_NOT_FOUND(존재 은닉). claim↔DB 재검증 403 · 미인증 401. generated 훅 useListHqTtsAnnouncementDispatches·getListHqTtsAnnouncementDispatchesQueryKey. apps/space /admin/announcements 행 [이력] 또는 송출 결과 배너 [이력 보기] → DispatchHistoryDialog(집계 헤더 + 매장 행 + 페이지네이션).
PATCH/api/v1/hq/dispatches/{id}/cancelcancelHqDispatchHQ_MANAGER#077 — 본사 TTS 안내방송 송출 취소, 204 No Content. PENDING dispatch 1건을 CANCELED 로 단방향 전이. 원자적 조건부 UPDATE(WHERE id AND hq_id AND status='PENDING') — 0행 영향이면 404 DISPATCH_NOT_FOUND(이미 PLAYED/CANCELED · 미존재 · 타 본사 모두 은닉, 점장 ack 멱등 패턴 미러). 같은 트랜잭션에 본사 audit 1건(HQ_ANNOUNCEMENT_DISPATCH_CANCELED) 기록 — audit INSERT 실패 시 함께 롤백(액션-감사 원자성). body 없음(요청 파라미터·본문 0). hqId 토큰 주체 도출 — 자기 본사 dispatch 만. 점장 player 자동 제외(점장 pending 조회는 WHERE status='PENDING' 만 매치 — 코드 변경 0). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. rate limit HQ_CONTROL 30/분(APP_RATE_LIMIT_HQ_CONTROL_PER_MINUTE) — 초과 시 429 RATE_LIMITED + Retry-After(error-codes). generated 훅 useCancelHqDispatch. apps/space /admin/announcements 송출 이력 다이얼로그 행 [취소]([재송출] 옆, PENDING 행만 노출) → 확인 strip \{매장명\} 송출을 취소하시겠습니까? [취소 확정] [닫기]. PLAYED 는 종착 — 취소 불가(시간 되감기 X). 잔여 후속: 다중 취소(F1) · 안내방송 단위 일괄 취소(F2).
POST/api/v1/hq/dispatches/{dispatchId}/revokerevokeHqDispatchHQ_MANAGER#077 확장 — 본사 TTS 안내방송 송출 원격 즉시중단(revoke), 204 No Content. PENDING dispatch 1건을 CANCELED 로 단방향 전이 + revoked_at=now set. cancel 과 운영 의도 분리 — cancel=재생 전 무효화, revoke=점장 player 가 그 멘트를 재생 중/직전일 때 즉시 강제 중단(best-effort, SSE 없이 점장 폴링 응답으로 전달). 원자적 조건부 UPDATE(WHERE id AND hq_id AND status='PENDING') — 0행 영향이면 404 DISPATCH_NOT_FOUND(종착(PLAYED/CANCELED)·미존재·타 본사 은닉, 멱등). 같은 트랜잭션에 본사 audit 1건(HQ_DISPATCH_REVOKED — cancel 과 별도 audit 액션) 기록 — audit INSERT 실패 시 함께 롤백. body 없음. hqId 토큰 주체 도출 — 자기 본사 dispatch 만. 점장 전달: 점장 GET /api/v1/store/announcements/pending 응답의 revokedDispatchIds(오늘 KST 윈도우 본인 매장 revoke 목록)에 dispatchId 노출 → player 가 폴링 갱신 시 재생 중 오버레이면 즉시 중단·재선출 차단(ack 안 함, best-effort). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. rate limit HQ_CONTROL 30/분(APP_RATE_LIMIT_HQ_CONTROL_PER_MINUTE) — 초과 시 429 RATE_LIMITED + Retry-After(error-codes). generated 훅 useRevokeHqDispatch. apps/space /admin/announcements 송출 이력 다이얼로그 행 [원격 중단]([취소] 옆, PENDING 행만) → 확인 strip + 성공 시 info banner(best-effort 안내).
GET/api/v1/hq/dispatch-calendargetHqDispatchCalendarHQ_MANAGER본사 예약 송출 캘린더 기간 조회(read-only). 200 DispatchCalendarResponse{from·to·events: DispatchCalendarEvent[]}(행=DispatchCalendarEvent{dispatchId·scheduledAt(UTC ISO, 예약=scheduled_at·즉시=created_at 의 COALESCE 파생)·storeId·storeName?(본사만 채워짐)·announcementTitle·kind(DispatchCalendarEventKind EMERGENCY/HQ_ANNOUNCEMENT/STORE_BROADCAST)·status(DispatchCalendarEventStatus SCHEDULED/PENDING/PLAYED/CANCELED)·isEmergency·scheduleId?(반복 전개분이면 규칙 id)}, effective time ASC → id ASC 결정적·0건이면 빈 배열). query from·to(KST day, 포함, YYYY-MM-DD) — from~to 최대 62일, 초과/역순 → 400 DISPATCH_CALENDAR_INVALID_RANGE. hqId 토큰 주체 도출(자기 본사 dispatch 만). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqDispatchCalendar·getGetHqDispatchCalendarQueryKey. apps/space /admin/announcements/schedules “캘린더” 탭(타임테이블과 공존) — HqDispatchCalendar(공용 DispatchCalendar 월 그리드, 보는 월 그리드 범위 ≤42칸만 fetch → 62일 cap 안전, FE 가 scheduledAt 을 KST day 로 변환해 셀 배치). FE-only(BE 백본 완비).
GET/api/v1/hq/audit/dispatcheslistHqAuditDispatchesHQ_MANAGER#067 — 본사 송출 audit 조회(read-only). 200 HqAuditListResponse{items,page,size,total}(행=HqAuditItem{id·occurredAt·actorEmail·actorRole(HqAuditItemActorRole HQ_MANAGER/OPERATOR_IMPERSONATING)·impersonatedByEmail?·action(HqAuditItemAction 5종 — DISPATCHED·CREATED·UPDATED·DELETED·DISPATCH_CANCELED, SPEC #077 확장)·targetType(HqAuditItemTargetType TTS_ANNOUNCEMENT)·targetId?·targetLabel?·detail?}). query from?·to?(ISO-8601 date-time)·actorAccountId?(UUID)·action?(ListHqAuditDispatchesActiontargetId?·q?(targetLabel/detail 부분일치, ≤100)·page?(0-base)·size?(1..100 clamp). 정렬 occurred_at DESC, id DESC 서버 고정(결정적). hqId 토큰 주체 도출 — 자기 본사 audit 만(타 본사 격리, WHERE hq_id = :hqId 강제). 기록은 dispatch 트랜잭션 내 HqAuditService.record — actor·시각·대상 스냅샷 append-only(audit INSERT 실패 시 dispatch 도 롤백, 원자성). impersonation 송출(운영자 위장)은 actorRole=OPERATOR_IMPERSONATING + impersonatedByEmail 동시 기록. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqAuditDispatches·getListHqAuditDispatchesQueryKey. apps/space /admin/audit 페이지(필터·페이지네이션 client state).
GET/api/v1/hq/audit/dispatches/exportexportHqAuditDispatchesHQ_MANAGER#070 — 본사 audit CSV 내보내기(F5 마감). 200 text/csv; charset=utf-8(BOM + RFC4180) + Content-Disposition: attachment; filename="hq-audit-{yyyyMMdd-HHmmss}.csv"; filename*=UTF-8''...(RFC5987 병기). query 는 listHqAuditDispatches 와 동일하되 page/size 만 제외(from?·to? ISO-8601 date-time·actorAccountId?·action?(ExportHqAuditDispatchesAction 5종 동일, SPEC #077)·targetId?·q? ≤100). 컬럼(한국어 헤더, BE-pure 코드값): 발생시각(KST)·액션·대상유형·대상라벨·대상ID·행위자 이메일·행위자 역할·임퍼소네이션 운영자 이메일·상세. 상한 EXPORT_MAX=50000 행 — 초과 시 잘림 + 응답 헤더 X-Export-Truncated: true(FE 가 잘림 경고 배너 노출). 정렬 occurred_at DESC, id DESC 서버 고정. hqId 토큰 주체 도출 — 자기 본사 audit 만(WHERE hq_id = :hqId 강제). CSV injection 방어(공용 AuditCsvWriter=·+·-·@·탭·CR 시작 셀에 ' prefix, SPEC #048 미러). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · @Size 400. generated 훅 미사용 — orval mutator(apiFetch)가 모든 응답을 res.text() → JSON 파싱이라 CSV 부적합. FE 는 공용 helper @/lib/csv-export.ts downloadCsvFromBackend 로 우회(브라우저에서 BFF /api/backend/v1/hq/audit/dispatches/export?... 직접 fetch → res.blob() → 앵커 download 속성 클릭). 401 감지 시 /login?next={현재경로} 풀 리다이렉트. apps/space /admin/audit 헤더 [CSV 내보내기] 버튼.
GET/api/v1/hq/audit/actorslistHqAuditActorsHQ_MANAGER#069 — 본사 audit 행위자 select 옵션(F9 마감). 200 HqAuditActorListResponse{items: HqAuditActorOption[]} (envelope 없음 — 상한 200 라 페이지네이션 미적용). 행 HqAuditActorOption{accountId·email·role(HqAuditActorOptionRole HQ_MANAGER/OPERATOR_IMPERSONATING)·occurrenceCount} — HQ_MANAGER actor 와 OPERATOR_IMPERSONATING 의 원본 운영자 둘 다 옵션. 정렬 occurrenceCount DESC, email ASC 서버 고정 · 상한 200(초과 시 occurrenceCount desc 상위만, 모자라면 FE 자유 입력 fallback). 쿼리 = hq_audit_log UNION ALL projection + outer GROUP BY(accountId, role, email은 같은 actor 의 최신 audit 스냅샷). hqId 토큰 주체 도출 — 자기 본사 actor 만(WHERE hq_id = :hqId 강제). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqAuditActors·getListHqAuditActorsQueryKey(["/api/v1/hq/audit/actors"]). apps/space /admin/audit 행위자 select(자유 입력 → select 전환, 옵션 0건이면 disabled + “감사 로그가 없습니다.”).
GET/api/v1/hq/ticketslistHqTicketsHQ_MANAGER#086 — 본사 본인 본사 CS 티켓 목록(read-only). 200 HqTicketListResponse{items,page,size,total}(행=HqTicketListItem{id·title·status(HqTicketListItemStatus OPEN/IN_PROGRESS/RESOLVED/CLOSED)·priority(HqTicketListItemPriority URGENT/HIGH/NORMAL/LOW)·commentCount·createdAt·updatedAt}). query q?(제목 부분일치, 대소문자 무시, ≤100)·status?(ListHqTicketsStatuspriority?(ListHqTicketsPrioritypage?(0-base)·size?(1..100 clamp). 정렬 created_at DESC, id ASC 서버 고정(결정적). hqId 토큰 주체 도출(요청 파라미터 없음, 타 본사 티켓 비노출 — verifyHqScope). soft-delete 제외. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqTickets·getListHqTicketsQueryKey. apps/space /admin/support 목록(필터·페이지네이션 client state, 행 클릭 → 상세).
POST/api/v1/hq/ticketscreateHqTicketHQ_MANAGER#086 — 본사 본인 본사 CS 티켓 생성. body CreateHqTicketRequest{title(≤200)·body(≤5000)·priority?(CreateHqTicketRequestPriority, @nullable, 누락 시 NORMAL default)} → 201 HqTicketDetailResponse(작성 직후 댓글 0건). hqId·submitterAccountId·submitterEmail 은 토큰 주체에서 도출 — 요청 본문에 직접 지정 불가(타 본사 격리). 초기 status=OPEN(BE D1). Location 헤더 /api/v1/hq/tickets/{id} 채움. claim↔DB 재검증 403 · 미인증 401. generated 훅 useCreateHqTicket. apps/space /admin/support/new 작성 폼(LOW/NORMAL/HIGH 3종 노출 — URGENT 는 운영자 판단, F 후속) → 성공 시 /admin/support/{id} replace.
GET/api/v1/hq/tickets/{id}getHqTicketDetailHQ_MANAGER#086 — 본사 본인 본사 CS 티켓 단건 상세. 200 HqTicketDetailResponse{id·title·body·status·priority·submitterEmail·createdAt·updatedAt·comments: HqTicketCommentItem[]} (댓글 스레드 생성순 오름차순). ticket.hqId ≠ 주체 hqId 또는 미존재/soft-delete → 404 TICKET_NOT_FOUND(타 본사·미존재 모두 은닉, BE D3). 운영자 ticket detail 의 INTERNAL 메모는 본사 응답에 포함되지 않음(별도 DTO 로 분리, BE D5 — 과노출 회피). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqTicketDetail·getGetHqTicketDetailQueryKey(["/api/v1/hq/tickets/{id}"]). apps/space /admin/support/[id] 5 섹션(헤더·메타·처리(상태/우선순위 변경, #108)·본문·댓글 스레드+추가 form).
GET/api/v1/hq/commercialslistHqCommercialsHQ_MANAGER#093 — 본사 CM송(광고/공지 음원) 목록. 200 HqCommercialListResponse{items,page,size,total}(행=HqCommercialListItem{id·title·audioUrl·durationSeconds·isActive·createdAt·updatedAt}). query q?(제목 부분일치, 대소문자 무시, ≤100)·isActive?(boolean, 미지정=전체)·page(0-base)·size(1..100 clamp). 정렬 created_at DESC, id ASC 서버 고정. hqId 토큰 주체 도출(타 본사 비노출 — WHERE hq_id = :hqId AND deleted_at IS NULL 강제, BE D3). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqCommercials·getListHqCommercialsQueryKey. apps/space /admin/commercials 목록(검색·활성 필터·페이지네이션).
POST/api/v1/hq/commercialscreateHqCommercialHQ_MANAGER#093 — 본사 CM송 생성. body CreateHqCommercialRequest{title(1~200)·audioUrl(≤2048)·durationSeconds(1~3600)} → 201 HqCommercialDetailResponse. 생성 시 isActive=true 기본(F1 사이클 삽입 대상). audioUrl 은 FE 가 음원 파일 업로드(별도 hook 후속, F1) 후 받은 Azure blob URL 을 전달 — 본 슬라이스는 음원 업로드 UI 미포함, 사용자가 공개 접근 URL 을 직접 입력한다(SPEC §D9 가드). hqId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useCreateHqCommercial. apps/space /admin/commercials/new 폼 → 성공 시 /admin/commercials/{id} 네비.
GET/api/v1/hq/commercials/{id}getHqCommercialDetailHQ_MANAGER#093 — 본사 CM송 단건 상세 (HqCommercialDetailResponse, list item 과 동일 필드 — audioUrl 포함). hqId ≠ 주체 또는 미존재/삭제 → 404 COMMERCIAL_SONG_NOT_FOUND(타 본사·미존재 모두 은닉, BE D3). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqCommercialDetail·getGetHqCommercialDetailQueryKey(["/api/v1/hq/commercials/{id}"]). apps/space /admin/commercials/[id] 4 섹션(정보/미리듣기/편집/삭제).
PATCH/api/v1/hq/commercials/{id}updateHqCommercialHQ_MANAGER#093 — 본사 CM송 수정 (부분 갱신). body UpdateHqCommercialRequest{title?(1~200, @nullable, null=미변경)·isActive?(@nullable, null=미변경)} → 200 HqCommercialDetailResponse. 두 필드 모두 null = no-op(현재 상태 그대로 200). audioUrl 변경은 후속(D2 — 재업로드 흐름 별도). hqId ≠ 주체/미존재/삭제 → 404 COMMERCIAL_SONG_NOT_FOUND(은닉). updatedAt 갱신. claim↔DB 재검증 403 · 미인증 401. generated 훅 useUpdateHqCommercial. apps/space /admin/commercials/[id] 편집 form([저장] — title + 활성 토글, 변경 없으면 disabled).
DELETE/api/v1/hq/commercials/{id}deleteHqCommercialHQ_MANAGER#093 — 본사 CM송 삭제(soft-delete), 204. UPDATE ... WHERE id AND hq_id AND deleted_at IS NULL(affected=0 → 404 COMMERCIAL_SONG_NOT_FOUND). blob 유지(복구 가능). claim↔DB 재검증 403 · 미인증 401. generated 훅 useDeleteHqCommercial. apps/space /admin/commercials/[id] 삭제 섹션의 2-step 인라인 confirm strip(404=이미 삭제됨 흡수 → 목록 복귀).
POST/api/v1/hq/tickets/{id}/commentsaddHqTicketCommentHQ_MANAGER#086 — 본사 본인 본사 CS 티켓 댓글 추가. body AddHqTicketCommentRequest{body(NotBlank,≤5000)} → 201 HqTicketCommentItem{id·body·authorRole=HQ_MANAGER·authorEmail(스냅샷)·createdAt} (생성된 댓글 1건). 전체 스레드는 detail 호출로 받는다(getHqTicketDetail invalidate). ticket.hqId ≠ 주체 hqId 또는 미존재/soft-delete → 404 TICKET_NOT_FOUND(타 본사 티켓 id 도 404 로 은닉). claim↔DB 재검증 403 · 미인증 401. generated 훅 useAddHqTicketComment. apps/space /admin/support/[id] 댓글 추가 form — 성공 시 getGetHqTicketDetailQueryKey(id) invalidate + 입력 초기화.
PATCH/api/v1/hq/tickets/{id}/statuschangeHqTicketStatusHQ_MANAGER#108 — 본사 본인 본사 CS 티켓 상태 변경(F1, 제한적 허용). body HqTicketStatusChangeRequest{status(HqTicketStatusChangeRequestStatus OPEN/IN_PROGRESS/RESOLVED/CLOSED)} → 200 HqTicketStatusChangeResponse{status}. 본사 허용 전이는 운영자 전이표의 부분집합: RESOLVED→CLOSED·RESOLVED→IN_PROGRESS·CLOSED→IN_PROGRESS 만(OPEN→*·IN_PROGRESS→RESOLVED 등 운영자 전담은 거부). 비허용 전이 → 409 TICKET_INVALID_STATUS_TRANSITION. hqId 격리 원자적 UPDATE(WHERE id AND hq_id AND status=from) — 타 본사 id·미존재 모두 404 TICKET_NOT_FOUND(은닉, BE D2). audit HQ_TICKET_STATUS_CHANGED(detail before→after) 같은 트랜잭션 hook. claim↔DB 재검증 403 · 미인증 401. generated 훅 useChangeHqTicketStatus. apps/space /admin/support/[id] 처리 섹션 — 현재 status 의 허용 전이 버튼만(RESOLVED→[확인 종료]/[재개], CLOSED→[재개], 그 외 안내), 성공 시 getGetHqTicketDetailQueryKey(id) invalidate.
GET/api/v1/hq/support/unread-signalgetHqSupportUnreadSignalHQ_MANAGER#119 F4·D2 — 본사 CS “새 답변” dot signal. 200 HqSupportUnreadSignalResponse{latestOperatorReplyAt?(@nullable)·openOrInProgressCount}. latestOperatorReplyAt = 본인 본사 ticket 들에 달린 운영자(OPERATOR) REPLY 댓글의 max createdAt(없으면 null) — ticket.updatedAt 은 댓글로 갱신되지 않으므로 댓글 createdAt 별도 조회(주의 2). openOrInProgressCount = 미해결(OPEN+IN_PROGRESS) ticket 수(보조). hqId 격리. FE 가 localStorage lastSeen(lm.support.lastSeen.hq.<hqId>)과 비교해 dot 판정(D1 클라 last-seen, 신규 테이블 0). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqSupportUnreadSignal. apps/space HQSidebar /admin/support 항목 dot(60초 폴링), /admin/support 목록 mount 시 lastSeen=now(D5).
PATCH/api/v1/hq/tickets/{id}/prioritychangeHqTicketPriorityHQ_MANAGER#108 — 본사 본인 본사 CS 티켓 우선순위 변경(F2, URGENT 제외). body HqTicketPriorityChangeRequest{priority(HqTicketPriorityChangeRequestPriority URGENT/HIGH/NORMAL/LOW)} → 200 HqTicketPriorityChangeResponse{priority}. 본사는 LOW/NORMAL/HIGH 만 설정 가능 — URGENT 요청 시 400 HQ_TICKET_PRIORITY_FORBIDDEN(운영자 트리아지 전담, 작성 폼 #086 정책과 일관, BE D3). hqId 격리 원자적 UPDATE(WHERE id AND hq_id) — 타 본사 id·미존재 404 TICKET_NOT_FOUND(은닉). 동일값 멱등 200(updatedAt 갱신, audit 생략). audit HQ_TICKET_PRIORITY_CHANGED(detail before→after) 같은 트랜잭션 hook. claim↔DB 재검증 403 · 미인증 401. generated 훅 useChangeHqTicketPriority. apps/space /admin/support/[id] 처리 섹션 — LOW/NORMAL/HIGH 세그먼트(URGENT 옵션 없음), 성공 시 detail invalidate.

Store Mode — 매장 본인 (/api/v1/store/*)

매장(STORE_MANAGER) 본인용 endpoint (#049). /api/v1/store/** prefix 매처 → hasRole("STORE_MANAGER") (SecurityConfig D1)가 1차 인가 경계. service 가 PrincipalScopeGuard 로 claim↔DB 재검증한다.

MethodPathoperationIdAuth도입
GET/api/v1/store/megetStoreMeSTORE_MANAGER#049 — 본인 매장 요약(StoreMeResponse — 매장명·유형·상태·소속 본사(id·name)·요금제·주소·청구 기준일·생성 시각). 토큰 claim 을 DB(OperatorAccount)로 재검증 — 비활성(SUSPENDED·WITHDRAWN)·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 매장 데이터 미존재 404. generated 훅 useGetStoreMe·getGetStoreMeQueryKey(["/api/v1/store/me"])
PATCH/api/v1/store/meupdateStoreMeSTORE_MANAGER#087 — 본인 매니저 계정 편집 (UpdateStoreMeRequest { name?: string | null } — 본인 매니저 본인 이름. null/생략 = 미변경, non-null 이면 1~50자). 응답 StoreMeResponse(GET 과 동일 DTO 재사용). email 변경 금지·비밀번호 변경 금지(별도 endpoint, /onboarding/change-password SPEC #003). 매장 정보 편집은 운영사/본사 영역(F1 후속). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 빈/blank name 400. generated 훅 useUpdateStoreMe. apps/space /store/profile 본인 매니저 이름 편집 form(본사 #085 정확 미러).
GET/api/v1/store/queuegetStorePlaybackQueueSTORE_MANAGER#056 — 본인 매장 재생 큐(StorePlaybackQueueResponse{active·source·playlistId·playlistName·total·truncated·reason·items[]}, 행=QueueItem{musicId·title·audioUrl·durationSeconds?}). 서버가 활성/기본 PL 을 셔플해 반환. SPEC #122(#060 F1) 동작 추가: 큐 빌드 시 effective plan(store.plan ?? hq.plan) 위반 음원을 라이브러리 단위로 제외하고 전달(plan null=무제약 전곡 통과, 전부 위반→reason=EMPTY_PLAYLIST — 계약 shape·새 reason code 불변, FE 무변경). 활성·기본 PL fallback 동일 필터. generated 훅 useGetStorePlaybackQueue. apps/space /store Classic player.
GET/api/v1/store/playlistslistStorePlaylistsSTORE_MANAGERSPEC #129 — 점장 활성 PL 선택 후보 목록. 본인 매장 소속 본사(store.hqId)의 PL 목록을 q(이름 부분검색, ≤100)·page(0-base)·size(1..100 clamp) 로 조회 → 200 StoreOwnPlaylistListResponse{items: StoreOwnPlaylistListItemResponse[], page, size, total}. 행={id·name·isDefault·libraryCount·status(파생 EMPTY/FALLBACK/ACTIVE/UNUSED)·active(현재 매장 활성 여부)·updatedAt}. 경로/쿼리에 hqId·storeId 없음 — 토큰 claim 을 DB 로 재검증해 본인 매장 소속 본사 PL 로만 스코프(타 본사 제외). 정렬 updated_at DESC. claim↔DB 재검증 403 · 미인증 401. generated 훅 useListStorePlaylists·getListStorePlaylistsQueryKey. apps/space /store/playlist 목록 — Store Active Playlist.
PATCH/api/v1/store/me/active-playlistsetStoreOwnActivePlaylistSTORE_MANAGERSPEC #129 — 점장 본인 매장 활성 PL 선택/해제, 200 StoreActivePlaylistResponse(적용 PL 요약). body SetStoreOwnActivePlaylistRequest{ playlistId?: UUID | null } — non-null 이면 그 PL 적용(자기 매장 소속 본사 활성 PL 이어야 함), null=활성 해제(본사 기본 PL fallback, #058 큐 동작). 타 본사·미존재·삭제 PL → 404 PLAYLIST_NOT_FOUND(존재 은닉). plan 위반 PL 선택해도 차단 안 함(큐 빌드 #122 가 위반 음원 필터). 경로에 storeId 없음 — 토큰 claim DB 재검증·본인 매장 스코프. 매장 데이터 미존재 404 STORE_NOT_FOUND. 실제 변경 시 매장 감사 1건(STORE_ACTIVE_PLAYLIST_CHANGED). 기존 운영사/본사 setStoreActivePlaylist(#055) 로직 재사용·마이그레이션 0. claim↔DB 재검증 403 · 미인증 401. generated 훅 useSetStoreOwnActivePlaylist → 성공 시 PL 목록·getStorePlaybackQueue·me invalidate. apps/space /store/playlist [선택]/[본사 기본으로].
GET/api/v1/store/announcements/pendinglistStorePendingAnnouncementsSTORE_MANAGER송출 슬라이스 — 본인 매장의 미재생(PENDING) 안내방송 송출 목록(PendingAnnouncementsResponse{items[]·revokedDispatchIds[]}, 행=PendingAnnouncementItem{dispatchId·announcementId·title·audioUrl·durationSeconds?·isEmergency(SPEC #082)}, created_at ASC). revokedDispatchIds(SPEC #077 확장) — 오늘 KST 윈도우 본인 매장에서 본사가 원격 즉시중단(revoke)한 dispatchId 목록(revoke 시 CANCELED 전이로 items 에선 자동 제외되므로 별도 신호로 노출). claim↔DB 재검증 403 · 미인증 401. generated 훅 useListStorePendingAnnouncements·getListStorePendingAnnouncementsQueryKey(["/api/v1/store/announcements/pending"]). apps/space /store player 가 20초 폴링해 곡 끝에 삽입 재생 + revokedDispatchIds 소비(재생 중 멘트 즉시 중단·재선출 차단). isEmergency 는 player 인터럽트 분기 근거.
POST/api/v1/store/announcements/{dispatchId}/ackackStoreAnnouncementSTORE_MANAGER송출 슬라이스 — 안내방송 재생 완료 ack. 원자 조건부 UPDATE(WHERE status='PENDING')라 첫 ack(PENDING)만 204, 중복 ack·이미 PLAYED·본인 매장 아님·미존재 dispatch → 모두 404 DISPATCH_NOT_FOUND(상태·존재 은닉). 효과는 멱등(상태 불변)이나 응답은 404 — “204 멱등”이 아니다. claim↔DB 재검증 403 · 미인증 401. generated 훅 useAckStoreAnnouncement. apps/space /store player 가 안내방송 onEnded/onError 시 호출 → pending invalidate, 404 는 “이미 소비됨”으로 흡수해 음악 복귀.
POST/api/v1/store/broadcasts/previewpreviewStoreBroadcastSTORE_MANAGER즉시방송 슬라이스 — 점장 즉시방송 미리듣기(합성). body BroadcastPreviewRequest{text(1~200자)·voice(BroadcastPreviewRequestVoice 5종)·isEmergency?(SPEC #082, null/생략=false)} → Typecast 합성(톤 NORMAL 고정)·Azure blob 저장 후 STORE_BROADCAST draft row 생성(dispatch 안 함) → 201 BroadcastPreviewResponse{announcementId·audioUrl·durationSeconds?·isEmergency}. storeId·hqId 는 토큰 주체에서 도출(요청 파라미터 없음). Typecast 토큰 미설정 503 TTS_TOKEN_NOT_CONFIGURED·외부 합성 실패 502 TTS_SYNTHESIS_FAILED(둘 다 row 미생성). claim↔DB 재검증 403 · 미인증 401. generated 훅 usePreviewStoreBroadcast. apps/space /store/broadcast/now TTS 탭이 미리듣기 게이트 1단계로 호출(긴급 옵션 ON 시 isEmergency:true 명시 전송, OFF 시 생략).
POST/api/v1/store/broadcasts/recordingrecordStoreBroadcastSTORE_MANAGER즉시방송 녹음 슬라이스 — 점장이 브라우저(MediaRecorder)로 녹음한 오디오를 업로드해 미리듣기 draft 생성. multipart file(녹음 Blob, MIME audio/webm·audio/mp4·audio/mpeg·≤10MB) + durationSeconds(query, 1~30초 서버 검증) + isEmergency(query, SPEC #082, null/생략=false) → STORE_BROADCAST draft row 생성(dispatch 안 함, TTS preview 와 동형) → 201 BroadcastRecordingResponse{announcementId·audioUrl·durationSeconds?·isEmergency}. 미지원 형식 400 RECORDING_UNSUPPORTED_FORMAT·빈 파일/10MB 초과/길이 범위 밖 400 RECORDING_INVALID_FIELD. storeId·hqId 토큰 주체 도출. generated 훅 useRecordStoreBroadcast(mutator apiFetch 가 BFF catch-all 경유, multipart 바이너리는 catch-all 이 arrayBuffer 로 통과 — 음원 업로드 uploadMusic 와 동일). apps/space /store/broadcast/now 녹음 탭이 미리듣기 게이트 1단계로 호출(긴급 옵션 ON 시 query isEmergency=true 명시 전송, OFF 시 생략 — 이후 sendStoreBroadcast 로 송출, TTS 와 공유).
POST/api/v1/store/broadcasts/{announcementId}/sendsendStoreBroadcastSTORE_MANAGER즉시방송 슬라이스 — 미리듣기 draft 를 본인 매장에 송출(즉시) 또는 예약 등록(SPEC #083). preview 응답의 announcementId 를 경로로 → 본인 매장 1개 PENDING announcement_dispatch row 생성(즉시) 또는 SCHEDULED row 적재(예약) → 201 BroadcastSendResponse{dispatchId}. body BroadcastSendRequest{isEmergency?(SPEC #082, null/생략 시 preview 단계 값 유지·true 면 마지막 확정)·scheduledAt?(SPEC #083, ISO-8601 nullable — null/생략=즉시 송출(기존 PENDING fan-out), non-null=예약 송출(SCHEDULED row 적재 후 본사 #078 디스패처가 도래 시 PENDING 자동 전이 — HqDispatchScheduler 코드 변경 0))}. isEmergency·scheduledAt 자유 조합 가능(긴급 예약 = 정당한 use case). FE 는 일반 즉시 송출이면 {} 빈 body, 그 외엔 해당 필드만 명시 전송. scheduledAt 검증(SPEC #083): <= now → 400 BROADCAST_SCHEDULED_AT_PAST(과거 차단) · > now + 1year → 400 BROADCAST_SCHEDULED_AT_TOO_FAR(1년 상한). 본사 점유 시각 충돌(SPEC #109): scheduledAt 검증 통과 후, 같은 매장에 본사가 예약(SCHEDULED, announcement.source='HQ')한 동일 시각(store_id + status='SCHEDULED' + scheduled_at 일치)이 이미 있으면 409 DISPATCH_SLOT_OCCUPIED(정확-시각 충돌만 차단 — 5분 슬롯·반복은 슬롯 모델 후속). 점장발 SCHEDULED·즉시 송출(scheduledAt 없음)은 검사 대상 아님. 미존재·타 매장·이미 소비된 draft → 404(TTS_ANNOUNCEMENT_NOT_FOUND/존재 은닉). claim↔DB 재검증 403 · 미인증 401. generated 훅 useSendStoreBroadcast. 송출(즉시) 또는 예약 시각 도래 후 점장 player 의 pending 폴링(listStorePendingAnnouncements)이 잡아 곡 끝에 삽입 재생 → ack(본사 송출과 동일 고리). apps/space /store/broadcast/now 미리듣기 게이트 2단계 + SPEC #083 즉시/예약 모드 토글(date/time input + KST→UTC ISO 정규화, 본사 #078 DispatchAnnouncementDialog 패턴 inline 미러).
GET/api/v1/store/broadcast-templateslistBroadcastTemplatesSTORE_MANAGER자주쓰는방송 슬라이스 — 본인 매장 자주 쓰는 방송 템플릿 전량 조회(페이지네이션 없음, updated_at DESC, 매장당 최대 20) → 200 BroadcastTemplateListResponse{items: BroadcastTemplateResponse[], total}. storeId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useListBroadcastTemplates·getListBroadcastTemplatesQueryKey(["/api/v1/store/broadcast-templates"]). apps/space /store/broadcast/now 자주 쓰는 방송 탭 목록.
POST/api/v1/store/broadcast-templatescreateBroadcastTemplateSTORE_MANAGER자주쓰는방송 슬라이스 — 템플릿 생성. body CreateBroadcastTemplateRequest{name(1~60)·text(1~200, 즉시방송 text 와 동일 제약)·voice(5종)} → 201 BroadcastTemplateResponse. 매장당 활성 템플릿이 20개 도달 시 409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED. storeId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useCreateBroadcastTemplate. apps/space 템플릿 탭 [+ 새 템플릿] 인라인 폼.
PUT/api/v1/store/broadcast-templates/{id}updateBroadcastTemplateSTORE_MANAGER자주쓰는방송 슬라이스 — 템플릿 수정. body UpdateBroadcastTemplateRequest{name(1~60)·text(1~200)·voice} → 200 BroadcastTemplateResponse. 본인 매장 활성 템플릿이 아니면(미존재·삭제·타 매장) 404 BROADCAST_TEMPLATE_NOT_FOUND(소유 은닉). storeId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useUpdateBroadcastTemplate. apps/space 템플릿 탭 행 [편집] 인라인 폼.
DELETE/api/v1/store/broadcast-templates/{id}deleteBroadcastTemplateSTORE_MANAGER자주쓰는방송 슬라이스 — 템플릿 소프트삭제(deleted_at 설정) → 204. 본인 매장 활성 템플릿이 아니면(미존재·재삭제·타 매장) 404 BROADCAST_TEMPLATE_NOT_FOUND(소유 은닉). storeId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useDeleteBroadcastTemplate. apps/space 템플릿 탭 행 [삭제] 2-step 확인.
GET/api/v1/store/scheduled-broadcastslistStoreScheduledBroadcastsSTORE_MANAGERSPEC #091 — 점장 예약 송출 목록. 본인 매장의 announcement_dispatch.status='SCHEDULED' row 를 scheduled_at ASC 로 조회(상위 50건, 페이지네이션 후속) → 200 StoreScheduledBroadcastListResponse{items: StoreScheduledBroadcastItem[]}. 각 항목은 dispatchId·announcementTitle·audioUrl·scheduledAt·isEmergency·createdAt. 본사 #078 디스패처가 도래 시 PENDING 으로 전이하기 전 row 만 노출(즉시/PENDING·종착/PLAYED·CANCELED 자동 제외). 경로 파라미터 없음 — storeId 토큰 주체 도출 → 타 매장 예약 비노출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useListStoreScheduledBroadcasts·getListStoreScheduledBroadcastsQueryKey(["/api/v1/store/scheduled-broadcasts"]). apps/space /store/broadcast/scheduled read-only 표 (즉시방송 페이지 [예약 목록] 링크에서 진입).
GET/api/v1/store/dispatch-calendargetStoreDispatchCalendarSTORE_MANAGER점장 본인 매장 예약 송출 캘린더 기간 조회(read-only). 200 DispatchCalendarResponse{from·to·events: DispatchCalendarEvent[]} — 본사 캘린더와 동일 DTO(DispatchCalendarEvent), 단 storeName 은 본인 매장 고정이라 null. query from·to(KST day, 포함, YYYY-MM-DD) — from~to 최대 62일·초과/역순 → 400 DISPATCH_CALENDAR_INVALID_RANGE. storeId 토큰 주체 도출 → 타 매장 비노출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetStoreDispatchCalendar·getGetStoreDispatchCalendarQueryKey. apps/space /store/broadcast/scheduled “캘린더” 탭(목록 today/all 탭과 공존, atom-grounded) — 본사와 같은 공용 DispatchCalendar 월 그리드 재사용(showStoreName=false). FE-only(BE 백본 완비).
POST/api/v1/store/scheduled-broadcasts/{dispatchId}/broadcast-nowbroadcastStoreScheduledNowSTORE_MANAGERSPEC #142 — 점장 예약 송출 즉시 방송, 201. 본인 매장 announcement_dispatch.status='SCHEDULED' row({dispatchId})의 안내방송(audio·announcementId·isEmergency)으로 새 IMMEDIATE dispatch(PENDING·scheduledAt≈now·본인 매장) 1건 생성 → 점장 player 폴링이 받아 재생(더킹, #141). 원본 SCHEDULED row 는 무변경(원래 시각에 정상 발화 — 즉시 방송은 별도 1회 추가 송출). 미존재·타 매장·SCHEDULED 아님 → 404 TTS_ANNOUNCEMENT_NOT_FOUND(소유·상태 은닉). storeId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. 성공 시 store_audit_log STORE_DISPATCH_BROADCAST_NOW 1건(target DISPATCH). generated 훅 useBroadcastStoreScheduledNow({dispatchId}). apps/space /store/broadcast/scheduled + player [다음 방송] 모달 행 [즉시 방송] → 1-step 확인 strip → mutate(SPEC #131 미리듣기 대체).
GET/api/v1/store/commercial-song/nextgetStoreNextCommercialSTORE_MANAGERSPEC #094 도입 · #104 라운드로빈 #094 F3 마감. 본인 매장 본사의 활성 CM송 중 매장 단위 라운드로빈 다음 1건(200 StoreCommercialNextResponse{id,audioUrl,durationSeconds}) 또는 204 No Content(CM 미등록). verifyStoreScope → store 의 hqId + lastCommercialSongId 확보 → findFirstByHqIdActiveAfterId(hqId, lastId)(WHERE id > :lastId AND is_active=TRUE AND deleted_at IS NULL ORDER BY id ASC LIMIT 1) → 0건/lastId null 이면 wrap-around findFirstByHqIdActive(hqId)(ORDER BY id ASC LIMIT 1). 결정된 next 의 id 로 store.lastCommercialSongId 갱신(같은 트랜잭션 dirty-checking). V34 store.last_commercial_song_id UUID NULL FK ON DELETE SET NULL 로 referenced CM hard-delete 시 자동 clear. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated raw fetcher getStoreNextCommercial + react-query 훅 useGetStoreNextCommercial. apps/space /store player 가 음악 곡 effective N회 ended 시점에 imperative 호출(react-query 캐시 미사용 — 매 호출이 BE 라운드로빈 다음 상태 반영) → 200 시 CM 삽입 재생 + ended 후 카운터 리셋 + 다음 음악, 204/실패 silent + 다음 음악. 인터럽트 우선순위 긴급 > 일반 안내방송 > CM. effective 빈도는 본사 default(#095) ?? 매장 override(#103, BE 계산).
PATCH/api/v1/store/dispatches/{id}/cancelcancelStoreDispatchSTORE_MANAGERSPEC #092 — 점장 본인 예약 송출 취소, 204 No Content. 본인 매장 announcement_dispatch.status='SCHEDULED' row 1건을 CANCELED 로 단방향 전이. 원자적 조건부 UPDATE(WHERE id AND store_id AND status='SCHEDULED') — 0행 영향이면 404 DISPATCH_NOT_FOUND(미존재·이미 종착·타 매장·PENDING 모두 은닉, 본사 #077 패턴 미러). 본사 #077 차이: 본사는 PENDING 도 취소 가능했지만 점장은 SCHEDULED 만 취소(본사가 즉시 보낸 PENDING 을 점장이 무효화하는 건 정책 위반 — SPEC #092 §D5). body 없음. storeId 토큰 주체 도출 — 자기 매장 dispatch 만. claim↔DB 재검증 403 · 미인증 401. audit 미생성(F2 후속). generated 훅 useCancelStoreDispatch. apps/space /store/broadcast/scheduled 행 inline [취소] → 헤더 아래 confirm strip **{제목}** 예약을 취소하시겠습니까? [취소 확정] [닫기] 미러. PLAYED·CANCELED 종착(목록에서 자동 제외). 잔여 후속: F2 점장 취소 audit 누적.
GET/api/v1/store/ticketslistStoreTicketsSTORE_MANAGERSPEC #112 — 점장 CS 티켓 목록. 본인 매장(store_id 토큰 주체 도출) 티켓을 q(제목, ≤100)·status(TicketStatus)·category(TicketCategory 5종)·page·size 필터로 조회 → 200 StoreTicketListResponse{items: StoreTicketListItem[], page, size, total}. item 에 category?(공용 테이블 nullable) 포함. 정렬 updated_at DESC, id ASC. WHERE store_id = :storeId 격리 — 타 매장 비노출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useListStoreTickets·getListStoreTicketsQueryKey. apps/space /store/support 목록(상태·분류 배지·필터).
POST/api/v1/store/ticketscreateStoreTicketSTORE_MANAGERSPEC #112 — 본인 매장 CS 티켓 생성. body CreateStoreTicketRequest{title(1~200)·body(1~5000)·category(필수, TicketCategory 5종)} → 201 StoreTicketDetailResponse(read-back, Location 헤더). 초기 status=OPEN·priority=NORMAL(점장 미지정·서버 고정)·store_id 토큰 주체·hq_id=null(과노출 차단 D1). category 누락 400. storeId 는 요청 본문에 지정 불가(타 매장 격리). claim↔DB 재검증 403 · 미인증 401. generated 훅 useCreateStoreTicket. apps/space /store/support/new 작성 폼(우선순위 미노출·category select 필수).
GET/api/v1/store/tickets/{id}getStoreTicketDetailSTORE_MANAGERSPEC #112 — 본인 매장 티켓 단건 상세 → 200 StoreTicketDetailResponse{id·title·body·status·category?·createdAt·updatedAt·comments[]}. 댓글은 kind=REPLY(운영자 INTERNAL 메모 비노출 D3), authorRole(STORE_MANAGER/OPERATOR) 포함, createdAt asc. 타 매장·미존재 모두 404 TICKET_NOT_FOUND(존재 은닉 D2). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetStoreTicketDetail·getGetStoreTicketDetailQueryKey. apps/space /store/support/[id] 상세(상태/우선순위 변경 UI 없음 — 읽기 전용).
POST/api/v1/store/tickets/{id}/commentsaddStoreTicketCommentSTORE_MANAGERSPEC #112 — 본인 매장 티켓에 점장 댓글(kind=REPLY) 추가. body AddStoreTicketCommentRequest{body(1~5000)} → 201 StoreTicketCommentItem(authorRole=STORE_MANAGER). 타 매장·미존재 404 TICKET_NOT_FOUND(은닉). claim↔DB 재검증 403 · 미인증 401. generated 훅 useAddStoreTicketComment → 성공 시 detail invalidate. apps/space /store/support/[id] 댓글 작성폼.
PATCH/api/v1/store/tickets/{id}/closecloseStoreTicketSTORE_MANAGERSPEC #121 — 점장 CS 티켓 확인 종료(RESOLVED→CLOSED). body 없음 → 200 StoreTicketDetailResponse(read-back, status=CLOSED). store_id 격리 원자적 UPDATE(WHERE id AND store_id AND status='RESOLVED'). 점장은 close 1종만(reopen·기타 전이 불가 — 본사 #108 의 부분집합). 비-RESOLVED 종료 시도 → 409 TICKET_INVALID_STATUS_TRANSITION · 미존재·타 매장 → 404 TICKET_NOT_FOUND(존재 은닉). 같은 트랜잭션 audit STORE_TICKET_CLOSED 1건(#114). claim↔DB 재검증 403 · 미인증 401. generated 훅 useCloseStoreTicket → 성공 시 getGetStoreTicketDetailQueryKey(id) invalidate. apps/space /store/support/[id] 메타 사이드 [확인 종료] 버튼(status=RESOLVED 일 때만 노출).
GET/api/v1/store/support/unread-signalgetStoreSupportUnreadSignalSTORE_MANAGER#119 F5·D2 — 점장 CS “새 답변” dot signal. 200 StoreSupportUnreadSignalResponse{latestOperatorReplyAt?(@nullable)}. 본인 매장 ticket 들에 달린 운영자(OPERATOR) REPLY 댓글의 max createdAt(없으면 null). 점장은 카운트 불요 — dot(boolean)만(D2). store_id 격리. FE 가 localStorage lastSeen(lm.support.lastSeen.store.<storeId>)과 비교해 dot 판정. 점장 안내방송 배지는 만들지 않음(D3 — player 가 PENDING 을 이미 자동 소비). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetStoreSupportUnreadSignal. apps/space 점장 player 헤더 [고객지원] Link dot(60초 폴링, 안내방송 20초와 별개 query), /store/support 목록 mount 시 lastSeen=now(D5).

역할별 인가 경계/api/v1/admin/**(OPERATOR) · /api/v1/hq/**(HQ_MANAGER) · /api/v1/store/**(STORE_MANAGER) 세 prefix 매처가 SecurityConfig 의 1차 경계다. 1차 통과 후에도 본인 조회·테넌트 스코핑 service 는 PrincipalScopeGuard 로 claim↔DB 를 재검증한다(claim 단일 소스 비신뢰 — Auth Flow · Auth Model).

Actuator (운영용)

MethodPathAuth도입
GET/actuator/healthpublic#001
GET/actuator/infopublic#001

OpenAPI

Path용도
/v3/api-docsOpenAPI JSON (orval source)
/swagger-ui.htmlSwagger UI
/swagger-ui/**Swagger UI assets

CORS 허용 origin

application.yml cors 섹션:

  • http://localhost:3000
  • https://space.linkmusic.io
  • https://admin.space.linkmusic.io
  • https://linkmusic-frontend-space.vercel.app
  • https://linkmusic-frontend-space-admin.vercel.app

methods: GET, POST, PATCH, PUT, DELETE, OPTIONS allowCredentials: true maxAge: 3600

후속 SPEC 예고 endpoints

SPECendpoint
일괄작업 (약관 재발송 등)POST /api/v1/admin/hq/bulk/*
음원 카탈로그 v1 (라이브러리·소비 UI·hard-purge)/api/v1/admin/libraries* — 업로드/교체·목록/단건 조회·소프트삭제는 #041/#042 라이브(위 Admin — Music). 만료 blob+row 영구삭제(hard-purge)·소비 UI·라이브러리 endpoint 는 후속
Billing v1/api/v1/admin/contracts*, /api/v1/admin/invoices*, /api/v1/admin/billing-keys*
장애·방송 stats (도메인 도착 시 /admin/stats 확장)— (#035 에서 CS 티켓·계정·매장 분포는 실데이터화)

References

  • backend OpenAPI: http://localhost:8080/v3/api-docs (dev)
  • Swagger UI: http://localhost:8080/swagger-ui.html
  • 생성된 TS client: packages/api-client/src/generated/
  • 표준 에러 응답: Error Codes · OpenAPI sync 정책: OpenAPI
  • SPEC #001~#049 (#009 결번)