API 카탈로그Endpoints

API Endpoints (전체 카탈로그)

기준 BE v0.140.0 / FE v0.197.0 — 2026-08-12. backend linkmusic-msa-space-was/.../api/. OpenAPI 단일 소스 — 로컬 bootRun/v3/api-docs(prod·staging 비활성, 아래 §OpenAPI).

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
changeTicketCategoryuseChangeTicketCategory · TicketCategoryChangeRequest
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 (+ #149 rate limit — 짧은 시간 반복 실패 시 429 RATE_LIMITED + Retry-After. BFF 가 자격증명 401 분기보다 먼저 잡아 그대로 surface(error-codes). FE 로그인 폼은 전용 재시도 안내로 분기)
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 (+ #150 — 임퍼소네이션 세션은 403 IMPERSONATION_FORBIDDEN_ACTION 으로 차단. 비밀번호 변경은 계정 주인 본인만)
POST/api/v1/auth/impersonate-exchangeimpersonateExchangepublic#005
POST/api/v1/auth/setup/verifyverifyAccountSetuppublic#145 — 계정 설정(setup) 링크 토큰 검증. body SetupVerifyRequest{token} → 200 SetupVerifyResponse{email·role·expiresAt}(폼 표시 전 계정 요약). 무효/만료/사용됨 → 400 ACCOUNT_TOKEN_INVALID. 토큰이 곧 인증(세션 불요). FE 공개 페이지 /setup?token=(admin·space 양 앱). generated 훅 useVerifyAccountSetup.
POST/api/v1/auth/setup/completecompleteAccountSetuppublic#145 — 계정 설정 완료. body SetupCompleteRequest{token·newPassword(8~100)} → 204(비번 설정·계정 활성·토큰 소비). 무효/만료 400 ACCOUNT_TOKEN_INVALID. generated 훅 useCompleteAccountSetup.
POST/api/v1/auth/password-reset/requestrequestPasswordResetpublic#145 — 비밀번호 재설정 요청. body PasswordResetRequestRequest{email} → 204. 존재 은닉(이메일 유무 무관 동일 응답 — 계정 열거 방지). FE /forgot-password(은닉 안내). generated 훅 useRequestPasswordReset.
POST/api/v1/auth/password-reset/confirmconfirmPasswordResetpublic#145 — 비밀번호 재설정 확정. body PasswordResetConfirmRequest{token·newPassword(8~100)} → 204. 무효/만료 400 ACCOUNT_TOKEN_INVALID. FE /reset-password?token=. generated 훅 useConfirmPasswordReset.
POST/api/v1/auth/email-change/confirmconfirmEmailChangepublic#145 — 이메일 변경 인증(새 주소로 발송된 링크). body EmailChangeConfirmRequest{token} → 204(email 을 newEmail 로 전환·토큰 소비). 무효/만료 400 ACCOUNT_TOKEN_INVALID · 전환 시 중복 재충돌 EMAIL_ALREADY_IN_USE. FE /email-change/verify?token=. generated 훅 useConfirmEmailChange.
POST/api/v1/operator/me/email-change/requestrequestOperatorEmailChangeOPERATOR#145 — 운영자 본인 이메일 변경 요청. body EmailChangeRequestRequest{newEmail} → 204(새 주소로 인증 링크·기존 주소로 알림 메일). 새 이메일 중복 400 EMAIL_ALREADY_IN_USE. FE /settings/profile 이메일 변경 섹션. generated 훅 useRequestOperatorEmailChange.
POST/api/v1/hq/me/email-change/requestrequestHqEmailChangeHQ_MANAGER#145 — 본사 매니저 본인 이메일 변경 요청. 동일 계약. FE apps/space /admin/settings. generated 훅 useRequestHqEmailChange. #150 — 임퍼소네이션 세션은 403 IMPERSONATION_FORBIDDEN_ACTION(이메일 변경은 계정 주인 본인만 — 운영사 대리 진입 차단).
POST/api/v1/store/me/email-change/requestrequestStoreEmailChangeSTORE_MANAGER#145 — 점장 본인 이메일 변경 요청. 동일 계약. FE apps/space /store/profile. generated 훅 useRequestStoreEmailChange.
POST/api/v1/admin/accounts/{accountId}/resend-setupresendAccountSetupOPERATOR#145 — 계정 설정(setup) 메일 재발송(이전 미사용 토큰 무효화 후 재발급). → 204. 미존재 404. FE 계정 발급 성공 화면 [설정 이메일 보내기]. generated 훅 useResendAccountSetup.

Admin — Email Templates (/api/v1/admin/email-templates*)

계정·인증 트랜잭션 이메일 4종(ACCOUNT_SETUP·PASSWORD_RESET·EMAIL_CHANGE_VERIFY·EMAIL_CHANGE_NOTICE)은 코드 소유(EmailTemplates) — 운영사 백오피스에서 미리보기·테스트 발송만(편집 없음·read-only). 미리보기/발송이 실제 EmailTemplates 를 그대로 재사용해 드리프트 0. FE: apps/admin /settings/email-templates설정.

MethodPathoperationIdAuth도입
GET/api/v1/admin/email-templateslistEmailTemplatesOPERATOR#146 — 4종 메타 목록 EmailTemplateListItem[]{ type, label, subject(실 발송과 동일), placeholders }. 미인증 401. generated 훅 useListEmailTemplates.
GET/api/v1/admin/email-templates/{type}/previewpreviewEmailTemplateOPERATOR#146 — 지정 종류를 고정 샘플 데이터로 렌더 → EmailTemplatePreviewResponse{ subject, html }. html 은 단일(BE 다크 분기 없음) — FE 가 iframe srcDoc + .force-dark 토글로 라이트/다크. 무효 {type} → 404 EMAIL_TEMPLATE_NOT_FOUND. generated 훅 usePreviewEmailTemplate.
POST/api/v1/admin/email-templates/{type}/test-sendtestSendEmailTemplateOPERATOR#146 — 고정 샘플로 렌더해 제목에 [테스트] 프리픽스를 붙여 to 주소로 발송. body EmailTemplateTestSendRequest{to(≤254)}EmailTemplateTestSendResponse{delivered, emailConfigured}. Azure 미설정(NoOp)이면 emailConfigured=false·delivered=false(실제 미발송 — FE 가 ‘메일 미설정’ 안내). rate limit accountId 단위 5회/분(초과 429). 무효 {type} → 404. generated 훅 useTestSendEmailTemplate.

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)
POST/api/v1/admin/hq/{hqId}/managers/{managerId}/reset-passwordresetHqManagerPasswordOPERATOR#158 — 본사 관리자(HQ_MANAGER) 임시 비밀번호 재설정 (HqManagerResetPasswordRequest { tempPassword 8~100 }HqManagerAccountResponse passwordMustChange=true + 활성 세션 즉시 무효화). ACTIVE 계정만 (SUSPENDED/WITHDRAWN → 409 ACCOUNT_INVALID_STATUS_TRANSITION). 본사/관리자 미존재 → 404 HQ_NOT_FOUND|HQ_MANAGER_NOT_FOUND. 임퍼소네이션 세션 → 403 IMPERSONATION_FORBIDDEN_ACTION(#148). 점장 resetStoreManagerPassword 대칭. generated 훅 useResetHqManagerPassword
POST/api/v1/admin/hq/{hqId}/managers/{managerId}/suspendsuspendHqManagerOPERATOR#158 — 본사 관리자 정지 (HqManagerSuspendRequest { reason NotBlank·max255 }, ACTIVE→SUSPENDED + 세션 무효화). 잘못된 전이 → 409 ACCOUNT_INVALID_STATUS_TRANSITION · 미존재 → 404 · 임퍼소네이션 → 403 IMPERSONATION_FORBIDDEN_ACTION. generated 훅 useSuspendHqManager
POST/api/v1/admin/hq/{hqId}/managers/{managerId}/reactivatereactivateHqManagerOPERATOR#158 — 본사 관리자 복구 (SUSPENDED→ACTIVE, body 없음). 잘못된 전이 → 409 · 미존재 → 404 · 임퍼소네이션 → 403. generated 훅 useReactivateHqManager
DELETE/api/v1/admin/hq/{hqId}/managers/{managerId}revokeHqManagerOPERATOR#158 — 본사 관리자 회수 (ACTIVE/SUSPENDED→WITHDRAWN, terminal + 세션 무효화). 잘못된 전이 → 409 · 미존재 → 404 · 임퍼소네이션 → 403. generated 훅 useRevokeHqManager
POST/api/v1/admin/hq/{hqId}/managers/{managerId}/resend-setupresendHqManagerSetupOPERATOR#158 — 본사 관리자 설정메일 재발송 (계정 setup 토큰 재발급 + setup 링크 이메일 재전송, 이전 미사용 토큰 무효화 → 204). ACTIVE 계정만 · 미존재 → 404 · 임퍼소네이션 → 403 IMPERSONATION_FORBIDDEN_ACTION. generated 훅 useResendHqManagerSetup

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

MethodPathoperationIdAuth도입
POST/api/v1/admin/stores/independentonboardIndependentStoreOPERATOR#005 / #011. #184 — 매장 + 점장 계정 한 트랜잭션: store.managerEmail 이 채워지면 그 값이 곧 점장 로그인 ID 가 되고 같은 트랜잭션에서 STORE_MANAGER 계정이 생성된다(임시 비밀번호는 서버 생성 · 응답 미노출 — D6). 미입력이면 “점장 미정” 으로 매장만 생성(D2). 응답 StoreOnboardingResponse{storeId, managerAccountId?, managerAccountCreated, setupEmailSent?}. 이메일 중복 → 409 DUPLICATE_EMAIL 이며 매장 생성도 롤백(D9 — 반쪽 상태 금지). 계정 설정(setup) 메일은 커밋 후 발송하고 결과를 setupEmailSent 로 알린다(false = 계정 진입 경로 없음 → 재발송 필요). rate limit STORE_PROVISION 계정당 20회/분 → 초과 429 RATE_LIMITED
POST/api/v1/admin/hq/{hqId}/storesonboardAffiliatedStoreOPERATOR#011 (가맹 매장 등록 — DIRECT/FRANCHISE, HqAffiliatedStoreOnboardingRequest). #184 — 위 independent 와 동일한 계정 통합·409 롤백·setupEmailSent·rate limit 규칙 적용
GET/api/v1/admin/stores/admin-listadminListStoresOPERATOR#019 (풀 필드 — 운영사 매장 목록, hqName join). #044: 검색·필터·페이지네이션 query q(매장명·본사명·주소 부분일치, max100)·status(ACTIVE|SUSPENDED|INACTIVEtype(DIRECT|FRANCHISE|INDEPENDENTplan(AI|TRUSThqId(#020 — 특정 본사 스코프, 미지정 시 전체)·hasManagerAccount(#184 D8 — false = 점장 계정 미발급 매장만, true = 유효 계정 보유 매장만, 미지정 시 전체. 술어는 status <> WITHDRAWN 이라 회수한 매장이 다시 “미발급” 으로 잡힌다 — 감사 AC-F1 재발급 진입점 복원 조건)·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. #184 이후 위치: 신규 매장은 등록 폼이 이메일 1회로 계정까지 만들므로(D1) 이 endpoint 는 기존 매장(백필하지 않은 D3 대상)·“점장 미정” 등록분·회수 후 재발급의 잔존 경로다. 임시 비밀번호 직접 입력 정책은 유지(D6 — 생성 경로만 서버 생성). FE 는 대상 매장의 managerEmail(상세 응답)을 폼에 프리필한다
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. ⚠️ UI 미사용 orphan(#170) — 운영사 매장별 활성 PL 화면이 제거돼 호출부 없음(BE 후속 cleanup)
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. ⚠️ UI 미사용 orphan(#170) — 운영사 매장별 활성 PL 지정 제거로 호출부 없음. 점장 self 경로(setStoreOwnActivePlaylist #129)가 이 로직을 재사용
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). ⚠️ UI 미사용 orphan(#170) — 운영사 화면 제거로 호출부 없음(BE 후속 cleanup)

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 **11종** — STORE_TICKET_CREATED·STORE_TICKET_COMMENT_ADDED·STORE_TICKET_CLOSED·STORE_PROFILE_UPDATED·STORE_PASSWORD_CHANGED·STORE_DISPATCH_CANCELED·STORE_DISPATCH_BROADCAST_NOW·**STORE_DEVICE_RENAMED·STORE_DEVICE_REVOKED·STORE_DEVICE_PLAYLIST_CHANGED**(SPEC #178)·STORE_ACTIVE_PLAYLIST_CHANGED)·targetType(StoreAuditListItemTargetType **5종** — DISPATCH·PLAYLIST·**STORE_DEVICE**·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?(ExportStoreAuditActionStoreAuditAction 11종 미러) · q? ≤100). 컬럼 8종(한국어 헤더): 발생시각KST·매장명·액션(한국어 라벨 매핑 — CS 티켓 생성/CS 티켓 댓글/프로필 수정/비밀번호 변경/예약 송출 취소. 매핑에 없는 enum(#121·#129·#142·#178 확장분)은 enum.name 으로 fallback — 데이터 손실 0)·대상 유형(enum.name)·대상 라벨·행위자 이메일·행위자 역할(점장/운영자 위장)·상세. 상한 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 · #167 (티켓 생성 — body TicketCreateRequest { title, body, priority, hqId?, storeId?, category? }TicketCreateResponse { id }). category?(TicketCreateRequestCategoryPLAYBACK/BROADCAST/BILLING/ACCOUNT/OTHER, nullable) 옵셔널 — 미지정=미분류(운영자 내부 티켓 허용). 점장 create 는 category 필수, 운영사 create 는 옵셔널
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}/categorychangeTicketCategoryOPERATOR#167 (분류 변경 — body TicketCategoryChangeRequest { category }(nullable) → TicketCategoryChangeResponse { category }). category=null 이면 미분류로 해제. 상태머신 없어 모든 값으로 자유 변경(전이 제약 409 없음). 동일값 재설정 멱등 200(updatedAt 갱신). audit TICKET_CATEGORY_CHANGED. 미존재 시 404 TICKET_NOT_FOUND
PATCH/api/v1/admin/tickets/{id}/assigneeassignTicketOPERATOR#027 (담당자 배정/해제 — body TicketAssignRequest { assigneeOperatorId? }, 미지정=해제)
POST/api/v1/admin/tickets/{id}/attachmentsuploadTicketAttachmentOPERATOR첨부파일(BE PR #324)multipart/form-data 파트 file + query commentId?(지정 시 그 댓글 첨부, 미지정 시 티켓 본문 첨부) → 201 TicketAttachmentUploadResponse{attachment: TicketAttachmentAdminItem}(운영자 응답만 visibility 포함). 제약: ≤10MB · 티켓당 5개 · MIME 5종(image/png·image/jpeg·image/gif·image/webp·application/pdf) + 매직바이트 검증(확장자만 바꾼 파일 400). 빈 파일·10MB 초과 400 TICKET_ATTACHMENT_INVALID_FIELD · 화이트리스트 외 400 TICKET_ATTACHMENT_UNSUPPORTED_TYPE · 5개 초과 409 TICKET_ATTACHMENT_LIMIT_EXCEEDED · 티켓·댓글 미존재 404 TICKET_NOT_FOUND · 서블릿 상한 초과 413 · accountId 분당 10회 초과 429. apps/admin /tickets/[id] 첨부 섹션(공유 TicketAttachments) → 성공 시 router.refresh(). FE 는 commentId 를 보낸다 — 첨부 섹션의 “첨부 대상” select 로 문의 본문(공유) ↔ INTERNAL 댓글(운영사 전용)을 고른다. 4MB 초과 파일은 이 endpoint 대신 업로드 티켓 경로(TICKET_ATTACHMENT_ADMIN)로 나간다(아래 Uploads 절).
GET/api/v1/admin/tickets/{id}/attachments/{attId}downloadTicketAttachmentOPERATOR첨부 다운로드(BE PR #324) — public blob URL 을 노출하지 않고 서버가 스토리지에서 읽어 stream(200 Content-Disposition: attachment + RFC5987 filename* + 원본 Content-Type). 미존재·타 티켓·blob 누락 404 TICKET_ATTACHMENT_NOT_FOUND · 미인증 401. generated 훅 미사용 — orval mutator(apiFetch)가 모든 응답을 res.text() 로 읽어 바이너리에 부적합. FE 는 응답의 downloadUrl 에 BFF prefix 를 붙여 같은 출처 <a download> 로 받는다(쿠키 자동 · 라우트 이동 없음 — 점장 player 모달에서 재생이 끊기지 않게).

첨부 가시성(INTERNAL) 은 운영자 응답에서만 판별한다 — 운영자 채널 응답의 attachments[]TicketAttachmentAdminItem(= 공용 필드 + commentId + visibility)이고, 비운영자 3 채널은 TicketAttachmentItem(commentId 는 있고 visibility 는 없다)이다. 필드 부재는 의도적이다 — 값이 항상 SHARED 일 뿐 아니라 필드의 존재 자체가 “INTERNAL 이라는 상태가 있다”를 알려 존재 은닉을 깨기 때문. FE 는 운영자 화면에서만 항목별 배지(INTERNAL = 내부 메모와 같은 앰버·자물쇠 / SHARED = “공유”)를 그리고, 업로드 시 commentId 로 대상 댓글을 지정한다. INTERNAL 판정은 서버가 댓글 kind 내린다.

본문 크기 경로 — 4MB 이하는 위 multipart endpoint(BFF 프록시 경유), 4MB 초과는 업로드 티켓 경로 (POST /api/v1/uploads/ticketsPOST /api/v1/uploads/files, 백엔드 직접 cross-origin)로 나간다. Vercel 서버리스 함수의 요청 본문 상한(4.5MB)이 BE 상한(10MB)보다 낮아 프록시 경유로는 그 사이 크기가 413 이 되기 때문이다(경계 4MB = multipart 오버헤드 여유). 10MB 전 구간이 실제로 업로드된다. 분기는 FE 공용 헬퍼 uploadTicketAttachmentFile(@linkmusic/api-client)이 담당하고, 두 경로의 201 응답 계약이 같아 호출부는 파싱을 분기하지 않는다.

Uploads — 대용량 직접 업로드 (/api/v1/uploads*)

음원·CM송·CS 티켓 첨부 파일 업로드는 브라우저 → Vercel 서버리스 BFF proxy(/api/backend) → 백엔드 경로를 타는데, Vercel 서버리스 함수의 요청 본문 4.5MB 하드 리밋(FUNCTION_PAYLOAD_TOO_LARGE)에 막혀 그 이상 파일이 413 으로 차단됐다. SPEC #177 부터 파일 본문만 백엔드로 직접(cross-origin) 전송해 Vercel 을 우회한다. 인증은 백엔드가 발급하는 단기·용도한정 업로드 티켓(별도 시크릿 UPLOAD_TICKET_SECRET 서명 JWT, ~10분). CS 첨부는 4MB 초과분만 이 경로를 쓰고 이하는 채널 multipart endpoint 를 그대로 쓴다(음원·CM 은 크기와 무관하게 항상 티켓 경로).

2 단계: (1) 작은 JSON 티켓 발급(proxy 경유·기존 JWT 인증) → (2) 발급 응답의 uploadUrl 로 파일 multipart 직접 POST(X-Upload-Ticket 헤더). FE 는 발급용 generated 훅 useCreateUploadTicket 만 쓰고, 파일 직접 업로드는 generated client 대신 수동 XHR(uploadViaTicket 헬퍼, @linkmusic/api-client) 로 호출한다 (upload.onprogress 진행률). 티켓·헤더 bearer 라 쿠키 미사용(withCredentials 불필요).

MethodPathoperationIdAuth도입
POST/api/v1/uploads/ticketscreateUploadTicketOPERATOR·HQ_MANAGER·STORE_MANAGER#177 (업로드 티켓 발급 — body UploadTicketRequest{ purpose, targetId? } (purpose = MUSIC_CREATE·MUSIC_REPLACE·COMMERCIAL_CREATE·COMMERCIAL_REPLACE·TICKET_ATTACHMENT_ADMIN·TICKET_ATTACHMENT_HQ·TICKET_ATTACHMENT_HQ_STORE·TICKET_ATTACHMENT_STORE) → 200 UploadTicketResponse{ ticket, uploadUrl }. proxy 경유·작은 JSON. role↔purpose 백엔드 검증 — OPERATOR 는 MUSIC_*·TICKET_ATTACHMENT_ADMIN, HQ_MANAGER 는 COMMERCIAL_*·TICKET_ATTACHMENT_HQ·TICKET_ATTACHMENT_HQ_STORE, STORE_MANAGER 는 TICKET_ATTACHMENT_STORE (그 외 조합 403 UPLOAD_PURPOSE_FORBIDDEN). *_REPLACEtargetId 필수 + 소유/존재 검증(COMMERCIAL 은 본인 본사 소유·MUSIC 은 운영사 전역, 미존재 404). TICKET_ATTACHMENT_*targetId = CS 티켓 id 필수 — 각 채널 티켓 조회와 같은 격리 술어로 접근 검증(미지정·미존재·타 테넌트·다른 채널 404 TICKET_NOT_FOUND). 미인증 401. 시크릿 미구성 503 UPLOAD_NOT_CONFIGURED)
POST/api/v1/uploads/filesuploadFileWithTicket티켓(permitAll)#177 (cross-origin 직접 업로드 — 헤더 X-Upload-Ticket: <ticket>, multipart 파트 file + purpose 별 폼 필드(MUSIC_CREATE=title·durationSeconds·musicSource / COMMERCIAL_CREATE=title·durationSeconds / *_REPLACE=durationSeconds·필요시 title / TICKET_ATTACHMENT_*=commentId?). targetId(음원·CM 교체 대상 id · CS 티켓 id)는 티켓에서만 취함(요청 파라미터 신뢰 금지). 티켓 purpose 로 기존 음원/CM/CS 첨부 서비스에 dispatch → MUSIC 은 MusicResponse(201) · COMMERCIAL 은 HqCommercialDetailResponse(201 create·200 replace) · CS 첨부는 각 채널 multipart endpoint 와 동일 계약(운영자 201 TicketAttachmentUploadResponse / 그 외 201 TicketAttachmentItem). audit actor 는 티켓 principal. 티켓 무효·만료·위조 401 UPLOAD_TICKET_INVALID · 빈 파일·20MB 초과·비-MP3 400 MUSIC_INVALID_FIELD/METADATA_PARSE_FAILED/COMMERCIAL_INVALID_FILE · CS 첨부 빈 파일·10MB 초과 400 TICKET_ATTACHMENT_INVALID_FIELD · 비허용 MIME·시그니처 불일치 400 TICKET_ATTACHMENT_UNSUPPORTED_TYPE · 5개 초과 409 TICKET_ATTACHMENT_LIMIT_EXCEEDED · 티켓/댓글 접근 불가 404 TICKET_NOT_FOUND · 교체 대상 미존재 404 · 분당 20회(IP — 계정 단위인 multipart 경로와 다름) 초과 429 RATE_LIMITED · 시크릿 미구성 503 UPLOAD_NOT_CONFIGURED)

FE 통합: 4 개 파일 호출부(음원 등록·음원 파일 교체·CM 등록·CM 파일 교체)가 공용 uploadViaTicket({ purpose, file, fields?, targetId?, onProgress }) (@linkmusic/api-client) 로 통일된다. 티켓 발급(proxy) → XHR 직접 업로드(진행률) → 성공 시 관련 목록·상세 캐시 invalidate. 기존 generated 훅(uploadMusic·replaceMusicFile·createHqCommercial·replaceHqCommercialFile)의 원 endpoint 는 유지되나 FE 는 직접 업로드 경로만 사용한다. FE 신규 env 없음uploadUrl 은 발급 응답값. UPLOAD_TICKET_SECRET 은 백엔드(Render) 전용 시크릿.

CS 첨부(4 채널) 는 그 위에 크기 분기를 얹은 uploadTicketAttachmentFile({ channel, ticketId, file, commentId? }) / 훅 useUploadTicketAttachmentFile(channel) 를 쓴다 — 4MB 이하는 채널 multipart endpoint, 초과는 티켓 경로. 점장 채널이 포함되므로 이 헬퍼는 store player 번들에도 유입되지만, 전송이 XHR(cross-origin)이라 라우트 이동·페이지 언로드가 없어 재생이 끊기지 않는다.

apps/space BFF proxy 스코프: POST /api/v1/uploads/tickets 의 세션 스코프는 경로가 아니라 본문 purpose 로 갈린다 — COMMERCIAL_*·TICKET_ATTACHMENT_HQ·TICKET_ATTACHMENT_HQ_STORE 만 impersonation-aware 이고 나머지(점장 TICKET_ATTACHMENT_STORE 포함)와 본문을 못 읽은 경우는 실 로그인(fail-closed)이다. 경로 단위로 두면 임퍼소네이션 cookie 가 남은 공용 단말에서 점장의 대용량 첨부가 본사 토큰으로 나가 403 이 된다.

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

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

SPEC #177: FE 는 음원 파일 본문을 이 uploadMusic/replaceMusicFile endpoint 로 직접 보내지 않고, 업로드 티켓 → POST /api/v1/uploads/files(직접 업로드) 경로로 우회한다(Vercel 4.5MB 리밋 회피). 아래 두 endpoint 는 계약·서버 로직(ID3 재기록·검증·audit) 그대로 유지되며 티켓 경로가 이들 서비스에 dispatch 한다.

업로드/교체(#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
GET/api/v1/admin/playlists/:id/schedulegetPlaylistScheduleOPERATORSPEC #171 — PL 기본 시간표 조회, 200 PlaylistScheduleResponse{playlistId·entries: PlaylistScheduleEntry[]}. entriesstartMinute 오름차순·[start,end) 반열림·30분 해상도(KST). 비어 있으면 시간표 없음(전체 셔플). 각 구간 startMinute·endMinute·libraryId·libraryName?. soft-deleted·미존재 → 404 PLAYLIST_NOT_FOUND. generated 훅 useGetPlaylistSchedule·getGetPlaylistScheduleQueryKey. apps/admin /playlists/{id} [시간표] 탭(기능).
PUT/api/v1/admin/playlists/:id/schedulesetPlaylistScheduleOPERATORSPEC #171 — PL 기본 시간표 통째 설정, 200 PlaylistScheduleResponse(설정 후 반환). body SetPlaylistScheduleRequest{entries: ScheduleEntryRequest[]}(@minItems 0 · @maxItems 48빈 배열 허용 = 시간표 해제(전체 셔플), 점장 매장 시간표(@minItems 1)와 다르다). 기존 시간표를 원자적으로 대체(부분 갱신 아님). 서버 검증: 각 구간 30분 배수·start<end·end≤1440(위반 400 SCHEDULE_INVALID_BOUNDS), 배열 내부·기존 구간과 비겹침(위반 409 SCHEDULE_TIME_OVERLAP — 그리드 페인팅이 구조적 예방), 라이브러리가 그 PL 멤버(비멤버 400 SCHEDULE_LIBRARY_NOT_MEMBER). FE 는 저장 시 팔레트 밖 stale libraryId 를 걸러 자연 제거. soft-deleted·미존재 PL → 404 PLAYLIST_NOT_FOUND. OPERATOR-only(본사 HQ_MANAGER 는 읽기만). generated 훅 useSetPlaylistSchedule → 성공 시 getPlaylistSchedule invalidate. apps/admin /playlists/{id} [시간표] 탭 [저장](전체 교체) + is_default PL 이면 산하 매장 파급 경고. is_default PL 파급: 이 PL 이 본사 기본이면 산하 매장의 기본 스케줄이 되며, 점장이 커스텀한 매장은 매장 override 우선(영향 없음).

플레이리스트는 라이브러리 묶음(곡 직접 아님 — 라이브러리 경유). 기본 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 참조.

Admin — Playback Logs (/api/v1/admin/playback-logs*)

운영사(OPERATOR)가 플랫폼 전체 매장·본사의 재생 로그를 조회·내보내는 read-only endpoint (#176 · #172 FU-3). 격리 없음(운영사 전권) — hqId·storeId 로 좁힐 수 있다. 신탁(TRUST) 음원 KOMCA 신고 근거 + 전량 재생 이력 열람.

MethodPathoperationIdAuth도입
GET/api/v1/admin/playback-logslistAdminPlaybackLogsOPERATOR#176 (#172 FU-3) — 플랫폼 전체 재생 로그(곡당 1행) 목록. query from·to(ISO-8601·필수·started_at 범위)·hqId?(본사 좁힘)·storeId?(매장 좁힘)·trustOnly(기본 true=신탁만·false=전량)·page(0-base)·size(1..100 clamp). 200 PlaybackLogListResponse{items:PlaybackLogItemDto[]·page·size·total}. store·hq·music 조인해 매장명·본사명·곡명을 낸다(폐점 매장·삭제 곡도 표기). 정렬 started_at desc → id desc 고정. 검증 from>to·조회 범위 366일 초과 → 400 PLAYBACK_LOG_INVALID_RANGE. OPERATOR-only(미인증 401·타 role 403). generated 훅 useListAdminPlaybackLogs·getListAdminPlaybackLogsQueryKey. apps/admin /settings/trust-playback-logs(PRD 18-5) 신탁 재생 로그 뷰어(기간·본사·매장 cascading·신탁 토글 필터·목록·페이지네이션·CSV 내보내기).
GET/api/v1/admin/playback-logs/exportexportAdminPlaybackLogsOPERATOR#176 — 플랫폼 전체 재생 로그 서버 CSV 내보내기. query 는 list 와 동일 필터(from·to·hqId?·storeId?·trustOnly) — page/size 없음(필터 매칭 전체 행·상한 50,000). 응답 UTF-8 BOM + RFC4180 escape + CSV injection guard(AuditCsvWriter 재사용) 적용 text/csv. 상한 초과 시 응답 헤더 X-Export-Truncated: true. 파일명 trust-playback-logs-{yyyyMMdd-HHmmss}.csv(KST · Content-Disposition filename* RFC5987). 검증 from>to·범위 366일 초과 400. OPERATOR-only(401/403). generated 훅 useExportAdminPlaybackLogs(단, FE 는 text/csv blob 스트림이라 BFF catch-all /api/backend/v1/admin/playback-logs/export 직접 fetch → downloadCsvFromBackend).

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 IN (PENDING, MISSED) 이고 is_hq_origin = true 인 distinct store_id 수. 조건 2가지가 핵심이다(감사 #11·#11-a): (1) MISSED 포함 — grace 초과 자동 만료·점장 player 의 재생 실패/폐기 보고로 종결된 송출도 “실제로 들리지 않은” 것이라 미도달이다(PENDING 만 세면 실패 ack 로 MISSED 가 된 송출이 카운트에서 사라져 “모두 도달” 로 보이는 역전이 생긴다). (2) 점장 직접 송출 제외 — 점장 즉시방송·점장 예약은 같은 hq 버킷에 들어가지만 본사 송출이 아니므로 이 지표(본사 송출 도달률)에서 뺀다. PLAYED·SCHEDULED·CANCELED 는 제외. dispatch 는 미ack 시 영구 PENDING 이라 전체 카운트는 노이즈 누적 → today(KST)로 한정(D4). hqId 토큰 주체 도출(타 본사 미집계). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqUndeliveredToday·getGetHqUndeliveredTodayQueryKey. apps/space /admin 대시보드 “오늘 송출 미도달 매장” 카드(60초 폴링·count>0 danger 강조 → SPEC #162 목록 다이얼로그 드릴다운).
GET/api/v1/hq/dashboard/undelivered-today/storesgetHqUndeliveredTodayStoresHQ_MANAGER#162 H11 — 오늘(KST) 본사 송출 미도달 매장 목록(count 카드 드릴다운). 200 HqUndeliveredStoreListResponse{items:[{storeId,storeName}],total} = count endpoint 와 완전히 동일한 조건(오늘 KST 윈도우 · status IN (PENDING, MISSED) · is_hq_origin = true · hqId 격리)에 Store 조인한 distinct store 목록(한 매장 여러 미도달=1행). 두 endpoint 가 같은 술어를 쓰므로 total == count endpoint 값이 항상 정합한다(감사 #11-a — 한쪽만 필터를 바꾸면 카드 숫자와 목록 길이가 어긋난다). 매장명 오름차순(동명 시 storeId 타이브레이크). query page(0-base)·size(1..100 clamp, 기본 20). hqId 토큰 주체 도출(타 본사 미반영). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqUndeliveredTodayStores. apps/space /admin 미도달 카드 클릭 시 UndeliveredTodayDialog(열릴 때만 조회·행별 [매장 상세] /admin/stores/{storeId}·에러 ErrorState 재시도·페이지네이션). /admin/audit dead-end(H11) 해소.
GET/api/v1/hq/dashboard/playback-statusgetHqPlaybackStatusHQ_MANAGER#172 FE-B — 산하 매장 음악 재생 상태 요약(미도달=방송 축과 별개인 음악 재생 축). 200 HqPlaybackStatusResponse{playingCount·silentCount·offlineCount·total·items[]}. SPEC #178 — 기기 단위 우선 집계: 기기 행(store_device_playback_status)이 하나라도 있는 매장은 그 기기들을 PLAYING > PAUSED > SILENT > OFFLINE 우선순위로 접고(“하나라도 재생 중이면 재생 중”), 없는 매장만 종전 store_playback_status(매장당 1행 heartbeat)로 폴백한다. 회수 기기의 유령 행은 store_device 조인 + deleted_at IS NULL 로 제외. 각 기기는 먼저 개별 staleness 로 OFFLINE 이 파생되고, lastSeenAt 은 기기들 중 최근값·현재 곡은 그 상태를 결정한 기기 중 첫 행(last_heartbeat_at DESC)이다. playingCount=PLAYING·silentCount=SILENT·offlineCount=OFFLINE(서버가 마지막 heartbeat staleness 로 파생) 매장 수, total=산하 미폐점 매장 전체 수. ⚠️ PAUSED 는 세 카운트 밖이라 playing+silent+offline != total 일 수 있다(합계로 쓰지 말 것 — DTO 주석 명시). items=매장별 HqPlaybackStatusStoreItem{storeId·storeName·state·lastSeenAt?·currentMusicId?·currentMusicTitle?}(매장명 오름차순). 아직 한 번도 보고 안 한 매장은 lastSeenAt=null. hqId 토큰 주체 도출(타 본사 0). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqPlaybackStatus·getGetHqPlaybackStatusQueryKey. apps/space /admin 대시보드 “재생 상태” 카드(60초 폴링·refetchIntervalInBackground:false·무음/offline>0 → warn 강조 + PlaybackStatusDialog 드릴다운: 카드 query items 를 필터해 무음·offline 매장명·상태·마지막 접속(KST)·현재곡 표시·행별 [매장 상세]).
GET/api/v1/hq/playback/summarygetHqPlaybackSummaryHQ_MANAGER#175 (#172 FU-2) — 본인 본사 재생 일/주/월 집계 조회. query bucket(DAY|WEEK|MONTHfrom·to(YYYY-MM-DD, KST, 포함). 200 HqPlaybackSummaryResponse{bucket·from·to·total·series[]·stores[]} — 롤업 playback_daily_rollup WHERE hq_id=:hqId AND day_kst BETWEEN :from AND :to 위의 조회(모델 변경 없음). total=기간 전사 합(playedMsTotal·playedMsTrust·trackCount·trackCountTrust), series=전사 버킷별 추이(bucketStart·playedMsTotal·playedMsTrust·trackCount, DAY=day_kst·WEEK=date_trunc('week') 월요일 시작·MONTH=date_trunc('month'), bucketStart asc·차트용), stores=매장별 기간 합(storeId·storeName·playedMsTotal·playedMsTrust·trackCount·trackCountTrust, playedMsTotal desc·테이블용). 검증: from<=to·범위 상한(최대 1년) 초과 400·bucket enum 오값 400. 폐점 매장 포함(과거 재생분 보존 — storeName 은 현재 store 조인, soft-deleted 매장명도 표기). hqId 토큰 주체 도출(타 본사 0). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. 준수율(영업시간 대비)은 이번 제외(FU-1 선행). generated 훅 useGetHqPlaybackSummary·getGetHqPlaybackSummaryQueryKey. apps/space /admin/playback 재생 리포트(기간 컨트롤·전사 KPI·커스텀 SVG 추이 차트·매장별 테이블, refetchIntervalInBackground:false).
GET/api/v1/hq/playback-logslistHqPlaybackLogsHQ_MANAGER#176 (#172 FU-3) — 자기 산하 매장 재생 로그(곡당 1행) 목록. query from·to(ISO-8601·필수·started_at 범위)·storeId?(산하 특정 매장 좁힘)·trustOnly(기본 true=신탁만·false=전량)·page(0-base)·size(1..100 clamp). 200 PlaybackLogListResponse{items:PlaybackLogItemDto[]·page·size·total}. store·hq·music 조인해 매장명·본사명·곡명을 낸다(폐점 매장·삭제 곡도 표기). 정렬 started_at desc → id desc 고정. 검증 from>to·조회 범위 366일 초과 → 400 PLAYBACK_LOG_INVALID_RANGE. hqId 격리verifyHqScope 로 토큰 주체 산하 매장만(타 본사 storeId 지정해도 0). claim↔DB 재검증 403 · 미인증 401. generated 훅 useListHqPlaybackLogs·getListHqPlaybackLogsQueryKey. apps/space /admin/playback/logs 신탁 재생 로그 뷰어(기간·매장·신탁 토글 필터·목록·페이지네이션·CSV 내보내기, 본사 필터 미노출).
GET/api/v1/hq/playback-logs/exportexportHqPlaybackLogsHQ_MANAGER#176 — 자기 산하 재생 로그 서버 CSV 내보내기. query 는 list 와 동일 필터(from·to·storeId?·trustOnly) — page/size 없음(필터 매칭 전체 행·상한 50,000). 응답 UTF-8 BOM + RFC4180 escape + CSV injection guard(AuditCsvWriter 재사용) 적용 text/csv. 상한 초과 시 응답 헤더 X-Export-Truncated: true. 파일명 trust-playback-logs-{yyyyMMdd-HHmmss}.csv(KST · Content-Disposition filename* RFC5987). hqId 격리(list 와 동일). 검증 from>to·범위 366일 초과 400. claim↔DB 재검증 403 · 미인증 401. generated 훅 useExportHqPlaybackLogs(단, FE 는 text/csv blob 스트림이라 BFF catch-all /api/backend/v1/hq/playback-logs/export 직접 fetch → downloadCsvFromBackend).
GET/api/v1/hq/storeslistHqStoresHQ_MANAGER#051 — 본인 본사 산하 매장 목록(HqStoreListResponse{items,page,size,total}). query q?(매장명·주소 부분일치, 대소문자 무시, ≤100)·status?(ListHqStoresStatustype?(ListHqStoresTypehasManagerAccount?(#184 D8 — 운영사 목록과 동일 술어. false 로 “계정 미발급” 매장을 색출해 POST /api/v1/hq/stores/{storeId}/managers 로 사후 발급한다)·page(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 도입(정지/복구/폐점·점장 발급/관리는 여전히 운영사 /api/v1/admin/stores/** 전용. 운영사 매장별 활성 PL 지정은 #170 에서 제거). 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 필드 없음). #184 (D1·D4) — 점장 계정 발급이 이 endpoint 에 통합됐다: managerEmail 이 채워지면 같은 트랜잭션에서 STORE_MANAGER 계정이 생성되고(그 이메일이 로그인 ID) 계정 설정 메일이 발송된다. 임시 비밀번호는 서버 생성 · 응답 미노출(D6). 미입력이면 “점장 미정” 으로 매장만 등록(D2) → 이후 POST /api/v1/hq/stores/{storeId}/managers 로 사후 발급. 검증 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 CreateHqStoreResponse{store: HqStoreDetailResponse, managerAccount?: HqStoreManagerIssueResult} — #184 로 flat → envelope 로 바뀐 breaking change(발급 결과를 조회·편집 공용 상세 DTO 에 섞지 않는다). FE 는 data.store.id 로 상세에 진입한다(예전 data.id 접근은 /admin/stores/undefined 로 깨진다). 이메일 중복 → 409 DUPLICATE_EMAIL + 매장 생성 롤백(D9, 고정 문구 — 입력 이메일 미포함으로 계정 열거 차단). rate limit STORE_PROVISION 20회/분 → 429 RATE_LIMITED. audit HqAuditAction.HQ_STORE_CREATED(발급 시 HQ_STORE_MANAGER_ISSUED 별도 row 추가) + 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 단일 등록을 행 단위로 미러한다. #184 (D6) — “담당자이메일” 컬럼이 점장 로그인 ID 로 승격됐다: 채워진 행은 매장 + 점장 계정이 함께 생성되고 임시 비밀번호는 서버 생성 + 응답에 노출하지 않는다(setup 메일 전용 진입). setup 메일은 행 트랜잭션 밖에서 루프 종료 후 일괄 발송한다(행마다 외부 왕복 = 1000행 상한에서 BFF 타임아웃 + 커넥션 점유). rate limit 은 별도 그룹 BULK_PROVISION 계정당 3회/분(요청 1건이 최대 1000통 유발) → 초과 429 RATE_LIMITED. 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, managerAccountCreatedCount, setupEmailFailedCount, results: BulkRowResult[]} — 행별 {rowNumber(1-base, 헤더 제외), status(SUCCESS|FAILED), storeId?, storeName?, errorCode?, errorMessage?, managerAccountCreated, setupEmailSent?}. successCount - managerAccountCreatedCount = “점장 미정” 으로 등록된 매장 수 · setupEmailFailedCount > 0 = 계정은 생겼지만 메일 미발송(재발송 필요). 이메일 중복 행은 errorCode=DUPLICATE_EMAIL그 행만 실패한다. 파일 자체 오류(헤더 불일치·빈 파일·파싱 불가)는 행 처리 전 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).
POST/api/v1/hq/stores/{storeId}/managersissueHqStoreManagerHQ_MANAGER#184 (D4) — 본사가 자기 산하 매장에 점장 계정을 사후 발급(신규 권한). 생성 시점을 놓친 매장의 유일한 복구 경로다 — “점장 미정” 등록(D2) · 이메일 중복으로 계정 없이 재등록(D9) · 점장 회수(WITHDRAWN) 후 재발급(D8 / 감사 AC-F1). 대상은 GET /api/v1/hq/stores?hasManagerAccount=false 로 색출. body IssueHqStoreManagerRequest{email(필수·@Email·≤255), name?(≤50)}임시 비밀번호 필드 없음(서버 생성 · 응답 미노출 — D6, 계정 설정 메일이 유일한 진입 경로). 응답 201 HqStoreManagerIssueResponse{storeId, managerAccount{managerAccountId, email, setupEmailSent?}}. setup 메일은 커밋 후 발송(setupEmailSent=false = 계정은 생성됐지만 메일 미발송 → 재발송 필요. 재발송은 OPERATOR-only 라 본사는 운영사에 요청). 타 본사·미존재 매장 → 403 PRINCIPAL_SCOPE_MISMATCH 로 은닉(존재 유무 미노출) · 정지 본사 403 AUTH_HQ_SUSPENDED · 이메일 중복 409 DUPLICATE_EMAIL(고정 문구) · rate limit STORE_PROVISION 20회/분 초과 429 RATE_LIMITED. 매장당 복수 점장 계정 허용. 발급마다 audit HqAuditAction.HQ_STORE_MANAGER_ISSUED(총 18종) + HqAuditTargetType.STORE. generated 훅 useIssueHqStoreManager. apps/space /admin/stores/[id] 점장 정보 섹션 [점장 발급] 다이얼로그(미발급·회수 상태에서만 노출, 폐점 매장 제외).
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/playlists/{id}/schedulegetHqPlaylistScheduleHQ_MANAGERSPEC #171 — 본인 본사 PL 기본 시간표 조회. 200 PlaylistScheduleResponse{playlistId·entries: PlaylistScheduleEntry[]} — 운영사(getPlaylistSchedule)와 동일 DTO. 본사 기본 시간표를 조회한다(편집은 setHqPlaylistSchedule PUT — 아래, 매장 조정은 점장 소관). PL.hqId ≠ 주체 또는 미존재 → 404 PLAYLIST_NOT_FOUND(타 본사 PL 존재 은닉). hqId 토큰 주체 도출. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqPlaylistSchedule·getGetHqPlaylistScheduleQueryKey. apps/space /admin/playlists/[id] [시간표] 탭 편집 그리드의 draft 소스.
PUT/api/v1/hq/playlists/{id}/schedulesetHqPlaylistScheduleHQ_MANAGERSPEC #171 — 본인 본사 PL 기본 시간표 통째 설정(본사 편집). 200 PlaylistScheduleResponse(설정 후 반환). body SetPlaylistScheduleRequest{entries: ScheduleEntryRequest[]}(@minItems 0 · @maxItems 48빈 배열 허용 = 시간표 해제(전체 셔플), 운영사 setPlaylistSchedule(OPERATOR)와 동일 DTO·동작). 기존 시간표를 원자적으로 대체(부분 갱신 아님). 서버 검증: 각 구간 30분 배수·start<end·end≤1440(위반 400 SCHEDULE_INVALID_BOUNDS), 배열 내부·기존 구간과 비겹침(위반 409 SCHEDULE_TIME_OVERLAP — 그리드 페인팅이 구조적 예방), 라이브러리가 그 PL 멤버(비멤버 400 SCHEDULE_LIBRARY_NOT_MEMBER). FE 는 저장 시 팔레트 밖 stale libraryId 를 걸러 자연 제거. PL.hqId ≠ 주체 또는 미존재/soft-deleted → 404 PLAYLIST_NOT_FOUND(타 본사 PL 존재 은닉). hqId 토큰 주체 도출. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useSetHqPlaylistSchedule → 성공 시 getGetHqPlaylistScheduleQueryKey invalidate. apps/space /admin/playlists/[id] [시간표] 탭 [저장](전체 교체) + is_default PL 이면 산하 매장 파급 경고 + 저장 중 탭·이탈 잠금(#18). is_default PL 파급: 이 PL 이 본사 기본이면 산하 매장의 기본 스케줄이 되며, 점장이 커스텀한 매장은 매장 override 우선(영향 없음).
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·#174 — 본사 CM송 생성(파일 업로드). consumes=multipart/form-data — body CreateHqCommercialBody{file(MP3 Blob)} + query title(1~200)·durationSeconds(1~3600) → 201 HqCommercialDetailResponse. #174: 구 JSON {title,audioUrl,durationSeconds} 를 대체 — 서버가 파일을 검증(MP3 magic byte 49 44 33/FF Fx + audio/mpeg + 20MB 이하 + 비어있지 않음, ID3 rewrite 미경유 as-is 저장, D2)한 뒤 Azure blob({prefix}/commercials/{id}.mp3)에 저장하고 audioUrl 을 채워 row 를 만든다. durationSeconds클라이언트 추출(FE Web Audio, D4). 생성 시 isActive=true 기본. 에러 400 두 종류 분리: 파일 문제(빈·비-MP3·20MB 초과) = COMMERCIAL_INVALID_FILE, title(1200)·durationSeconds(13600) 범위 위반 = 전역 검증 400(별개 code). hqId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. audit HQ_COMMERCIAL_CREATED. generated 훅 useCreateHqCommercial(data:{file}·params:{title,durationSeconds}). apps/space /admin/commercials/new 폼(MP3 파일 선택 + duration 자동 추출) → 성공 시 /admin/commercials/{id} 네비. #177: FE 는 파일 본문을 업로드 티켓(COMMERCIAL_CREATE) → POST /api/v1/uploads/files 직접 업로드로 우회(Vercel 4.5MB 리밋 회피, uploadViaTicket) — 이 endpoint 계약·서버 로직은 유지되고 티켓 경로가 dispatch.
PUT/api/v1/hq/commercials/{id}/filereplaceHqCommercialFileHQ_MANAGER#174 — 본사 CM송 오디오 파일 교체. consumes=multipart/form-data — body ReplaceHqCommercialFileBody{file(새 MP3 Blob)} + query durationSeconds(1~3600) → 200 HqCommercialDetailResponse. 음원 PUT /{id}/file 미러 — 같은 blob key({prefix}/commercials/{id}.mp3) 덮어쓰기(id 기반, orphan 없음)로 audioUrl(동일 URL·내용 교체)·durationSeconds 갱신. 파일 검증은 create 와 동일(D2). 파일 문제 400 COMMERCIAL_INVALID_FILE / durationSeconds 범위 위반 = 전역 검증 400. 기존 title·isActive 는 불변(PATCH 로 별도 관리). hqId ≠ 주체/미존재/삭제 → 404 COMMERCIAL_SONG_NOT_FOUND(은닉). claim↔DB 재검증 403 · 미인증 401. audit HQ_COMMERCIAL_FILE_REPLACED. generated 훅 useReplaceHqCommercialFile(id·data:{file}·params:{durationSeconds}). apps/space /admin/commercials/[id] 오디오 교체 섹션(새 MP3 선택 + duration 재추출). #177: FE 는 파일 본문을 업로드 티켓(COMMERCIAL_REPLACE, targetId 는 티켓에만) → POST /api/v1/uploads/files 직접 업로드로 우회(uploadViaTicket) — 이 endpoint 계약·서버 로직은 유지되고 티켓 경로가 dispatch.
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] 5 섹션(정보/미리듣기/오디오 교체(#174)/편집/삭제).
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·durationSeconds) 교체는 별도 endpoint PUT /{id}/file(#174) — PATCH 는 메타(title·isActive)만. 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.
POST/api/v1/hq/tickets/{id}/attachmentsuploadHqTicketAttachmentHQ_MANAGER첨부파일(BE PR #324) — 본사 본인 발신 CS 티켓에 파일 첨부. multipart/form-data 파트 file + query commentId? → 201 TicketAttachmentItem(bare — 비운영자 채널이라 visibility 없음). 제약·에러는 운영사 endpoint 와 동일(≤10MB · 티켓당 5개 · MIME 5종 + 매직바이트 · 400/409/404/429). hqId 격리 404 은닉. apps/space /admin/support/[id] 첨부 섹션(공유 TicketAttachments) → 성공 시 detail invalidate. 4MB 초과는 업로드 티켓 경로(TICKET_ATTACHMENT_HQ).
GET/api/v1/hq/tickets/{id}/attachments/{attId}downloadHqTicketAttachmentHQ_MANAGER첨부 다운로드(BE PR #324) — 서버 stream(Content-Disposition: attachment + RFC5987). 미존재·타 티켓 404 TICKET_ATTACHMENT_NOT_FOUND. FE 는 downloadUrl + BFF prefix 로 같은 출처 <a download>(generated 훅 미사용 — mutator 가 text 파싱).

하위 매장 CS (본사 → 산하 매장 점장 CS, /api/v1/hq/store-tickets/* · #173)

/api/v1/hq/tickets/*본사↔운영사 CS(본사 본인 발신)이고, 아래 /api/v1/hq/store-tickets/*산하 매장 점장이 올린 CS(#112, storeId≠null)를 본사가 조회·처리하는 별도 endpoint 다(#173 D4 — 본사발 격리 보존: 위 listHqTickets 등엔 storeId IS NULL 가드가 추가돼 매장 티켓이 안 샌다). 점장 티켓은 생성 시 hqId=store.hqId 로 채워지고(#173 D3), 기존 티켓은 V56 백필된다.

MethodPathoperationId권한설명
GET/api/v1/hq/store-ticketslistHqStoreTicketsHQ_MANAGER#173 — 산하 매장 CS 목록(read). 200 HqStoreTicketListResponse{items,page,size,total}(행=HqStoreTicketListItem{id·title·status·priority·category?(@nullable)·storeId·storeName·commentCount·createdAt·updatedAt}). query q?(제목 ≤100)·status?·priority?·category?(ListHqStoreTickets*storeId?(특정 매장)·page?·size?. 정렬 created_at DESC, id ASC. hqId 토큰 주체 도출 + storeId≠null — 타 본사·본사발 티켓 비노출. generated 훅 useListHqStoreTickets. apps/space /admin/store-support 목록(필터·페이지네이션 client state).
GET/api/v1/hq/store-tickets/{id}getHqStoreTicketDetailHQ_MANAGER#173 — 산하 매장 CS 단건 상세. 200 HqStoreTicketDetailResponse{id·title·body·status·priority·category?·storeId·storeName·submitterEmail(점장)·createdAt·updatedAt·comments: HqStoreTicketCommentItem[](REPLY 만, 3-role STORE_MANAGER/OPERATOR/HQ_MANAGER)}. 운영자 INTERNAL 메모 제외(BE 필터). ticket.storeId 의 소속 hqId ≠ 주체 hqId 또는 미존재 → 404 TICKET_NOT_FOUND(타 본사·타 매장·미존재 은닉). generated 훅 useGetHqStoreTicketDetail·getGetHqStoreTicketDetailQueryKey. apps/space /admin/store-support/[id].
POST/api/v1/hq/store-tickets/{id}/commentsaddHqStoreTicketCommentHQ_MANAGER#173 — 본사 답변. body AddHqStoreTicketCommentRequest{body(≤5000)} → 201 HqStoreTicketCommentItem{…·authorRole=HQ_MANAGER·authorEmail·createdAt}. 전체 스레드는 detail 로 받음(invalidate). hqId 격리 404 은닉. generated 훅 useAddHqStoreTicketComment. apps/space /admin/store-support/[id] 답변 form.
PATCH/api/v1/hq/store-tickets/{id}/statuschangeHqStoreTicketStatusHQ_MANAGER#173 D1 — 상태 변경(표준 전이 전체). body HqStoreTicketStatusChangeRequest{status} → 200 HqStoreTicketStatusChangeResponse{status}. 본사가 하위 매장 CS 의 실질 처리 주체 → OPEN→IN_PROGRESS→RESOLVED→CLOSED + RESOLVED/CLOSED→IN_PROGRESS(재개) 표준 전이 허용(단 assign·priority 는 운영사 전담 — 이 endpoint 없음). 비허용 전이 409 TICKET_INVALID_STATUS_TRANSITION. hqId 격리 원자적 UPDATE — 타 본사 id·미존재 404 은닉. generated 훅 useChangeHqStoreTicketStatus. apps/space /admin/store-support/[id] 처리 섹션(현재 status 의 허용 전이 버튼).
GET/api/v1/hq/store-support/unread-signalgetHqStoreSupportUnreadSignalHQ_MANAGER#173 — 하위 매장 CS 미해결 수·새 활동 signal. 200 HqStoreSupportUnreadSignalResponse{openOrInProgressCount·latestStoreActivityAt?(@nullable)}. openOrInProgressCount = 산하 매장 미해결(OPEN+IN_PROGRESS) 수(미해결 count 배지), latestStoreActivityAt = 산하 매장 CS 의 최신 점장 작성 시각(신규 티켓·신규 답글 중 최신, 없으면 null — 새 활동 dot). hqId 격리. FE 가 localStorage lastSeen(lm.support.lastSeen.hq-store.<hqId>)과 비교해 dot 판정. generated 훅 useGetHqStoreSupportUnreadSignal. apps/space HQSidebar /admin/store-support(매장 문의) 항목 count 배지 + dot(60초 폴링·refetchIntervalInBackground:false), 목록 mount 시 lastSeen=now.
POST/api/v1/hq/store-tickets/{id}/attachmentsuploadHqStoreTicketAttachmentHQ_MANAGER첨부파일(BE PR #324) — 산하 매장 CS 티켓에 본사가 파일 첨부. multipart/form-data 파트 file + query commentId? → 201 TicketAttachmentItem(bare). 제약·에러 동일(≤10MB · 5개 · MIME 5종 + 매직바이트). hqId 격리 404 은닉. apps/space /admin/store-support/[id] 첨부 섹션 → 성공 시 detail invalidate. 4MB 초과는 업로드 티켓 경로(TICKET_ATTACHMENT_HQ_STORE).
GET/api/v1/hq/store-tickets/{id}/attachments/{attId}downloadHqStoreTicketAttachmentHQ_MANAGER첨부 다운로드(BE PR #324) — 서버 stream. 404 TICKET_ATTACHMENT_NOT_FOUND. FE 는 같은 출처 <a download>.

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 정확 미러).
POST/api/v1/store/devicesregisterStoreDeviceSTORE_MANAGERSPEC #178 — 매장 기기 등록(멱등), 200 StoreDeviceResponse. body RegisterStoreDeviceRequest{deviceKey(≤64, 클라 발급 UUID·localStorage 보관)·label?(≤50, 생략 시 서버가 기기 N)}. 같은 deviceKey 재호출은 기존 기기를 그대로 반환하고 lastSeenAt 만 갱신 — 새로고침·재부팅으로 기기가 늘지 않는다. INSERT 는 네이티브 ON CONFLICT … DO NOTHING upsert동시 요청에서도 500 이 나지 않는다(affected 를 보지 않고 항상 재조회해 그 행을 반환 = 멱등 200). 종전의 “saveAndFlush → 제약 위반 catch → 재조회”는 flush 중 위반이 세션을 rollback-only 로 마킹해 커밋에서 UnexpectedRollbackException → 500 이 나갔다. 등록은 audit 하지 않는다(player 마운트마다 일어나는 멱등 동작). 매장당 상한 4대(StoreDevice.DEFAULT_DEVICE_LIMIT) 초과 시 409 DEVICE_LIMIT_EXCEEDED(서버 자동 회수 없음 — 점장이 목록에서 정리 후 재시도). 상한 판정은 신규 등록일 때만(이미 등록된 기기는 상한에 막히지 않는다). 경로에 storeId 없음 — 토큰 주체(본인 매장) 스코프. claim↔DB 재검증 403 · 미인증 401. generated 훅 useRegisterStoreDevice. apps/space /store player 마운트 시 useStoreDevice 가 1회 호출 — Store Devices.
GET/api/v1/store/deviceslistStoreDevicesSTORE_MANAGERSPEC #178 — 본인 매장 기기 목록, 200 StoreDeviceListResponse{items: StoreDeviceResponse[]}. 등록 순(created_at)·회수(soft-delete)된 기기 제외·페이지네이션 없음(상한 4대). lastSeenAt 이 “안 쓰는 기기” 판단 근거다. claim↔DB 재검증 403 · 미인증 401. generated 훅 useListStoreDevices·getListStoreDevicesQueryKey. apps/space /store/devices 목록(+ player 위 모달).
PATCH/api/v1/store/devices/{id}renameStoreDeviceSTORE_MANAGERSPEC #178 — 기기 이름 변경, 200 StoreDeviceResponse. body RenameStoreDeviceRequest{label(1~50, non-blank)}. 미존재·이미 회수됨·타 매장 기기는 모두 404 STORE_DEVICE_NOT_FOUND(존재 은닉). claim↔DB 재검증 403 · 미인증 401. generated 훅 useRenameStoreDevice. apps/space /store/devices 행 인라인 편집.
DELETE/api/v1/store/devices/{id}revokeStoreDeviceSTORE_MANAGERSPEC #178 — 기기 회수(soft-delete), 204 No Content. 상한에 걸렸을 때 점장이 안 쓰는 기기를 정리하는 유일한 경로 — 서버는 오래된 기기를 자동 회수하지 않는다(오프라인과 폐기를 구분할 수 없어 잘못 회수하면 재생 중인 기기가 끊긴다). 회수한 PC 를 다시 켜면 같은 deviceKey 로 새 기기 id 를 받아 재등록된다(V57 partial unique 가 deleted_at IS NULL 조건이라 재등록 가능). 미존재·이미 회수됨·타 매장 → 404 STORE_DEVICE_NOT_FOUND. generated 훅 useRevokeStoreDevice. apps/space /store/devices 행 [정리](2단계 확인).
GET/api/v1/store/devices/{id}/active-playlistgetStoreDevicePlaylistSTORE_MANAGERSPEC #178 — 기기별 활성 PL 조회, 200 StoreDevicePlaylistResponse{deviceId·playlistId?·appliedAt?}. playlistId=null 이면 지정 없음 = 매장 기본(매장 활성 PL → 본사 기본 PL)을 따른다는 뜻. 지정했던 PL 이 그 사이 삭제됐으면 playlistId=null 로 정규화해 반환한다(삭제된 목록이 선택된 것처럼 보이지 않게 — 큐 해석과 동일 기준). PL soft-delete 트랜잭션이 기기 지정도 함께 해제하므로(clearByPlaylistId) 이 정규화는 이중 안전이다. 기기 미존재·회수됨·타 매장 → 404 STORE_DEVICE_NOT_FOUND. generated 훅 useGetStoreDevicePlaylist(enabled: !!idgetGetStoreDevicePlaylistQueryKey. apps/space /store/playlist/store/schedule화면 표시의 근거로 소비한다 — 목록 응답의 active 는 “이 매장의 활성 PL 인지”라 기기 스코프에서는 근거가 될 수 없기 때문이다(Store Active Playlist).
PATCH/api/v1/store/devices/{id}/active-playlistsetStoreDevicePlaylistSTORE_MANAGERSPEC #178 — 기기별 활성 PL 지정/해제, 200 StoreDevicePlaylistResponse. body SetStoreDevicePlaylistRequest{playlistId?: UUID | null} — non-null 이면 이 기기만 그 PL 을 재생하고(같은 매장의 다른 PC 는 무영향), null=지정 해제(매장 기본 → 본사 기본 PL 로 복귀). 자기 본사 PL 이 아니거나 삭제된 PL → 404 PLAYLIST_NOT_FOUND(존재 은닉) · 기기 미존재·회수됨·타 매장 → 404 STORE_DEVICE_NOT_FOUND. 반영은 다음 큐 조회부터. generated 훅 useSetStoreDevicePlaylist. apps/space /store/playlist [선택]/[본사 기본으로] — 기기가 등록돼 있으면 매장 단위 setStoreOwnActivePlaylist 대신 이 경로를 쓴다.
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 불변). 활성·기본 PL fallback 동일 필터. 재생 불가 URL 가드(BE #284): http(s) 가 아닌 스킴(local:// 등)의 음원은 큐에서 제외하며, PL 에 곡은 있는데 전부 제외돼 0 이 되면 reason=UNPLAYABLE_SOURCES 로 빈 PL(EMPTY_PLAYLIST)과 구분한다(점장에게 “곡을 추가하세요” 대신 문의를 안내하기 위한 신호). total상한·재생 불가 필터 적용 후 값(= items 크기)이고, truncated 판정은 필터 이전 후보 수 기준이라 total < 500 이면서 truncated=true 인 조합이 정상적으로 나온다(PL 규모 신호). SPEC #178 기기 스코프(옵션·하위호환): query deviceId 를 주면 해석 우선순위가 기기 지정 PL(source=DEVICE) → 매장 활성 PL(ACTIVE) → 본사 기본 PL(DEFAULT) → 없음(NONE) 4단이 된다(기존 2단 폴백 위에 1단 추가). deviceId생략하면 종전과 완전히 동일하게 매장 단위로 해석하며(구버전 클라이언트 호환), 타 매장·회수된 기기 id 는 에러가 아니라 조용한 매장 단위 폴백이다(재생은 어떤 경우에도 멈추지 않아야 한다). generated 훅 useGetStorePlaybackQueue. apps/space /store Classic player — 기기 등록이 끝난 뒤(resolved) 큐를 조회하고, deviceId 가 채워질 때 쿼리 키가 바뀌므로 placeholderData 로 직전 큐를 유지해 재생 리셋을 막는다.
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/me/schedulegetStoreScheduleSTORE_MANAGERSPEC #171 FE-2 — 본인 매장 현재 적용 시간표 조회, 200 StoreScheduleResponse{playlistId?:UUID|null·playlistName?:string|null·isOverride·entries:StoreScheduleEntry[]·availableLibraries:ScheduleLibraryOption[]}. override(매장 맞춤)가 있으면 그 구간 + isOverride=true, 없으면 본사 기본 + isOverride=false. query deviceId?(UUID, 선택 — SPEC #178): 주면 큐와 동일한 3단 해석(기기 지정 PL → 매장 활성 PL → 본사 기본 PL, 공용 StorePlaylistResolver)으로 대상 PL 을 정하고, 생략하면 종전 2단(store.active_playlist_id → 본사 기본 PL). 타 매장·회수된 기기 id 는 에러가 아니라 조용한 폴백. 대상 PL 없으면 playlistId=null·isOverride=false·entries=[]. 구간은 startMinute 오름차순·[start,end) 반열림·30분 해상도(KST). availableLibraries = 활성 PL 멤버 라이브러리(그리드 편집 팔레트, position 순) — entrieslibraryId 는 쓰기 시점에만 이 집합의 부분집합(제거 후 stale 가능·dtos). 경로 파라미터 없음 — 토큰 claim DB 재검증·본인 매장 스코프. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 매장 미존재 404 STORE_NOT_FOUND. generated 훅 useGetStoreSchedule(params?)·getGetStoreScheduleQueryKey · params 타입 GetStoreScheduleParams{deviceId?}. apps/space /store/schedule 은 기기가 등록돼 있으면 항상 deviceId 를 싣는다(기능).
POST/api/v1/store/me/schedule/customizecustomizeStoreScheduleSTORE_MANAGERSPEC #171 FE-2 — 본사 기본을 매장 사본으로 복사·시딩, 200. 본사 기본 시간표를 그대로 복제해 매장 override(isOverride=true)를 시작한다. 이미 override 존재 → 409 SCHEDULE_ALREADY_CUSTOMIZED · 활성 PL 없음 → 409 SCHEDULE_NO_ACTIVE_PLAYLIST(FE 는 둘 다 재조회로 자연 교정). query deviceId?(SPEC #178, getStoreSchedule 과 동일 의미 — 큐와 같은 3단 해석). storeId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useCustomizeStoreSchedule(변수 { params?: CustomizeStoreScheduleParams }) → 성공 시 getStoreSchedule invalidate. apps/space /store/schedule [눌러서 커스텀 시작하기].
PUT/api/v1/store/me/schedulesetStoreScheduleSTORE_MANAGERSPEC #171 FE-2 — 매장 override 시간표 전체 교체, 200. body SetStoreScheduleRequest{entries:ScheduleEntryRequest[]}(148개 · @minItems 1). 각 구간 startMinute(01410)·endMinute(30~1440)·libraryId — 30분 배수·start<end·해당 PL 멤버. 경계 위반 → 400 SCHEDULE_INVALID_BOUNDS · 팔레트 밖 라이브러리 → 400 SCHEDULE_LIBRARY_NOT_MEMBER · 구간 겹침 → 409 SCHEDULE_TIME_OVERLAP(그리드 페인팅이 구조적으로 예방). FE 는 저장 시 팔레트 밖 stale libraryId 를 걸러 자연 제거. 반영은 다음 30분 슬롯 경계부터(즉시 아님). query deviceId?(SPEC #178) — 안 실으면 기기 지정 PL 로 재생 중인 PC 에서 저장이 200 이어도 그 PC 재생에 아무 효과가 없는 조용한 no-op 가 된다(시간표 스코프가 (store_id, playlist_id) 이기 때문). storeId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useSetStoreSchedule(변수 { data, params? }) → 성공 시 getStoreSchedule·getStorePlaybackQueue invalidate. apps/space /store/schedule [저장].
DELETE/api/v1/store/me/scheduledeleteStoreScheduleSTORE_MANAGERSPEC #171 FE-2 — 매장 override 삭제(본사 기본 복귀), 204 No Content. 매장 맞춤 시간표를 지우고 본사 기본을 다시 따른다(isOverride=false). 되돌릴 수 없음(danger confirm). override 없어도 멱등(204). query deviceId?(SPEC #178) — 되돌리기 다이얼로그는 부모의 조회·저장과 같은 params 를 prop 으로 받아 같은 PL 을 가리킨다. storeId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useDeleteStoreSchedule(변수 { params? }) → 성공 시 getStoreSchedule·getStorePlaybackQueue invalidate. apps/space /store/schedule [본사 기본으로 되돌리기] 확인 모달.
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 가 폴링해 음악 위에 동시재생(더킹 + 오버레이 · SPEC #141) — 주기는 SSE 연결 중 60초 / 미연결 20초(SPEC #180 D6, SSE 는 가속 채널·폴링이 안전망) + revokedDispatchIds 소비(재생 중 멘트 즉시 중단·재선출 차단). isEmergency 는 player 인터럽트 분기 근거. 재생 불가 스킴(local:// 등) audioUrl 도 서버는 거르지 않고 그대로 내려준다(BE #284) — 거르면 방송 자체가 소실되고, 재생 실패는 이미 ack(outcome=FAILED) → MISSED(PLAYBACK_FAILED) 채널이 같은 최종 상태로 처리한다(서버는 WARN 로그만). SPEC #178 확장: (a) 응답 item 에 playAt(절대 재생 시각, ISO-8601 nullable) 추가 — 전 기기가 이 시각에 맞춰 동시에 재생한다. 현재 값을 채우는 경로는 본사 단건 송출(dispatchHqTtsAnnouncement)뿐이다(즉시 = 발행 + 2초 · 예약 = scheduledAt). 본사 반복 예약 전개·점장 송출은 null 이라 종전대로 받는 즉시 재생하며, 과거 시각도 즉시 재생이다. (b) query deviceId(옵션) — 주면 PENDING 에 더해 최근 5분(DEVICE_FANOUT_GRACE) 안에 PLAYED 로 종착했지만 이 기기는 아직 재생하지 않은 송출까지 함께 내려 전 기기 재생을 보장한다(먼저 폴링한 기기가 20초 안에 재생을 마치면 늦게 폴링한 기기가 방송을 영영 못 보던 문제). 이 기기가 이미 ack 한 송출은 제외(중복 재생 방지). 생략 시 종전 매장 단위 동작 · 타 매장·회수된 기기 id 는 매장 단위로 조용히 폴백.
POST/api/v1/store/announcements/stream-ticketcreateStoreAnnouncementStreamTicketSTORE_MANAGERSPEC #180 — 실시간 통지(SSE) 스트림 티켓 발급, 200 StreamTicketResponse{ticket·streamUrl·expiresInSeconds}. EventSource 는 커스텀 헤더를 실을 수 없어 Bearer 로 스트림에 붙을 수 없다 — 발급만 기존 인증·BFF 경로로 하고 연결은 티켓(쿼리 파라미터)으로 한다. 응답 streamUrl 은 티켓까지 포함된 완전한 절대 URL 이라 FE 는 그대로 EventSource 에 넘긴다(백엔드 base URL 수기 조립 금지 — 프록시 경로 계약 어긋남 재발 방지). 티켓 = HS256 stateless JWT (시크릿은 업로드 티켓과 공유하되 type=stream-ticket claim 으로 상호 사용 차단 · 새 env 0 · DB·마이그레이션 0), TTL expiresInSeconds(기본 60초) — 재연결할 때마다 새로 발급받는다(이미 맺어진 커넥션 수명 30분과는 무관). 티켓의 매장 id 가 구독 스코프이며 연결 시점에 계정을 DB 로 재검증한다(정지·회수·소속 변경 시 거부). rate limit = 계정(principal) 단위 30회/분 → 429 RATE_LIMITED(정상 backoff 재연결은 여유롭게 통과). 티켓 시크릿·백엔드 base URL 미구성 시 503 — 그래도 방송은 폴링(안전망)으로 그대로 수신된다. claim↔DB 재검증 403 · 미인증 401. generated 훅 useCreateStoreAnnouncementStreamTicket. apps/space /store player use-announcement-stream.ts 가 최초 연결·재연결마다 호출한다.
GET/api/v1/store/announcements/streamstreamStoreAnnouncements티켓(?ticket= · permitAll 경로)SPEC #178 도입 · #180 확장 — 본사 즉시방송 실시간 통지(SSE), 200 text/event-stream. 이벤트 순서: 구독 직후 connected 1회(연결 확정 신호 — FE 는 이때부터 폴링을 완화한다) → 새 송출마다 announcement(신호만 — 기기는 그때 GET /store/announcements/pending 으로 실제 내용을 가져간다. 두 경로의 계약을 하나로 유지) → 25초 주기 keepalive : ping(SSE comment — 브라우저가 JS 핸들러로 노출하지 않는다). 통지 지점은 (1) 본사 즉시 송출 (2) 예약 도래(SCHEDULED→PENDING) (3) 본사 원격 즉시중단(revoke) 셋이다(#180 D7 — 없으면 폴링 완화가 예약 정시성·원격 중단 반응을 20→60초로 회귀시킨다). 인증은 ticket 쿼리 파라미터로만 한다(stream-ticket 발급분). 티켓 없음·무효·만료·다른 용도(업로드 티켓)는 본문 없는 401(fail-closed — 스트림 응답에는 에러 envelope 를 실을 수 없어 상태코드가 계약이다). rate limit = IP 단위 60회/분 → 429(permitAll 경로라 IP 기준. 폴링 안전망은 계속 동작). 커넥션 수명 30분. ⚠️ 단일 인스턴스 전제 — 구독자 registry 가 인메모리라 스케일아웃 시 SSE 만 동시성을 잃고 폴링으로 동작한다(Redis pub/sub 은 별도 SPEC). 폴링은 안전망으로 유지 — SSE 연결 중 60초 / 미연결 20초(#180 D6)라 커넥션이 끊겨도 방송이 소실되지 않는다. apps/space /store player use-announcement-stream.ts브라우저 → 백엔드 직접 연결한다(BFF 프록시 경유 아님 · Vercel maxDuration·상시 과금 회피).
POST/api/v1/store/announcements/{dispatchId}/ackackStoreAnnouncementSTORE_MANAGER송출 슬라이스 — 안내방송 재생 결과 보고 ack. 선택 본문 AckAnnouncementRequest{ outcome?: PLAYED | FAILED | SKIPPED } — 본문 생략·outcome 생략은 PLAYED(하위 호환, 종전 호출과 완전 동일). PLAYED=실제 재생 완료(PENDING→PLAYED, played_at 기록) · SKIPPED=재생을 시도조차 않고 폐기(시작 시 backlog drain) → 즉시 PENDING→MISSED(missedReason=SKIPPED) · FAILED=오디오 로드/재생 실패 보고. 재시도 정책은 서버 소유(감사 #11) — FAILED 는 즉시 종착이 아니라 playback_failure_count 를 +1 하고, 3회 미만이면 그 송출을 PENDING 으로 유지해 다음 pending 폴링에 그대로 재전달한다(같은 dispatchId 가 다시 내려온다). 3회째에 PENDING→MISSED(missedReason=PLAYBACK_FAILED)로 종결. 무한 재전달은 도래+grace(10분) 자동 만료가 상한. 응답은 종착이든 재전달 대기든 동일한 204 — 클라이언트는 상태를 판단하지 않는다. 어느 outcome 이든 원자 조건부 UPDATE(WHERE status='PENDING')라 중복 ack·이미 종착(PLAYED/MISSED/CANCELED)·본인 매장 아님·미존재 dispatch → 모두 404 DISPATCH_NOT_FOUND(상태·존재 은닉, 첫 결론을 덮어쓰지 않음). 효과는 멱등(상태 불변)이나 응답은 404 — “204 멱등”이 아니다. claim↔DB 재검증 403 · 미인증 401. generated 훅 useAckStoreAnnouncement(변수 {dispatchId, data}). apps/space /store player 가 onEnded→PLAYED · onError/워치독 강제종료→FAILED · backlog drain→SKIPPED 로 호출 → pending invalidate, 404 는 “이미 소비됨”으로 흡수해 음악 복귀. FE 는 자체 재시도 카운터를 두지 않고 실패할 때마다 그대로 FAILED 를 보고한다(재전달된 dispatchId 를 로컬 영구 블랙리스트로 막으면 서버 재시도가 무력화된다 — 짧은 쿨다운만 건다). SPEC #178 확장: 선택 본문에 deviceId 추가 — 주면 결과가 dispatch_device_ack(dispatch × device)에 기기별로 기록돼, 한 매장의 여러 PC 가 같은 방송을 재생해도 첫 기기만 204 를 받고 나머지가 404 나던 문제가 사라진다(이미 다른 기기가 PLAYED 로 바꿔놔 affected=0 이어도 404 로 만들지 않는다). 종착 정책 = 하나라도 PLAYED 면 그 송출은 매장에 들린 것이고, 활성 기기 전부가 실패/폐기를 보고했을 때만 기존 실패 경로(FAILED 누적 → 임계치 MISSED · SKIPPED → MISSED)를 탄다. 생략하면 종전 매장 단위 ack · 타 매장·회수된 기기 id 도 매장 단위 ack 로 처리. SPEC #180 확장(계측): 선택 본문에 signalSource(SSE|POLLING) · receivedAt · startedAt(둘 다 ISO-8601) 추가 — 각각 그 송출을 알게 된 채널 · 신호를 받은 시각 · 실제 재생을 시작한 시각이다(FE 가 서버 시각 오프셋을 보정해 보낸다). 서버는 커밋 이후 별도 트랜잭션에서 announcement_dispatchsignal_source·signal_received_at·playback_started_at 에 원값을 저장하고 signal_latency_ms = signal_received_at − COALESCE(scheduled_at, created_at) 를 확정 계산한다(즉시방송 리드타임 튜닝의 근거). 전부 선택이고, 알 수 없는 값·형식 오류·시각 비신뢰는 400 이 아니라 미보고(NULL)로 흡수한다 — 계측이 재생 결과 보고를 실패시키지 않는다(클라 시계가 30초 skew·1시간 상한을 벗어나면 지연만 NULL 로 두고 원값은 보존). 생략하면 종전과 완전히 동일(하위호환 · 백필 없음).
POST/api/v1/store/playback/reportreportStorePlaybackSTORE_MANAGER#172 FE-A — 점장 player 재생 상태·로그 보고(본사 재생 가시성의 적재 채널). 200 PlaybackReportResponse{recordedLogCount}. body PlaybackReportRequest{state·currentMusicId?·logs?}. 매 호출이 store_playback_status(매장당 1행) upsertstate(PLAYING/PAUSED/SILENT)·currentMusicId·last_heartbeat_at=now. ⚠️ state=OFFLINE 은 보고 불가 → 400 PLAYBACK_INVALID_STATE(OFFLINE 은 서버가 마지막 heartbeat staleness 로 파생하는 값). currentMusicId·logs[].libraryId 실존 안 하면 null 정규화(무시). logs(선택, @maxItems 200) — 곡 전환/종료 시에만 채운다(idle heartbeat 은 비움). 각 PlaybackLogRequest{musicId·libraryId?·startedAt·playedMs} 를 곡의 music.musicSource(TRUST/AI)로 is_trust 파생해 play_log(전량·신탁 신고 근거)에 insert + playback_daily_rollup(매장×일 KST, 신탁/비신탁 분리) 가산. 멱등 — 같은 (store, musicId, startedAt) 로그는 재전송이어도 1회만 적재(중복 skip·롤업 이중 가산 없음). 미등록 곡 id 로그는 무시하고 나머지 계속 적재(부분 성공). recordedLogCount = 이번 신규 적재 로그 수(멱등·미등록 skip 제외). storeId·hqId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useReportStorePlayback. apps/space /store player useStorePlaybackReport(fire-and-forget side-channel — 오디오 파이프라인 무간섭, report 실패·네트워크 오류를 전부 삼켜 재생 끊김 방지)가 곡 전환 시 logs 동반 + idle 5분 타이머 state-only + 상태 전환 즉시 전송, 네트워크 실패 시 로컬 큐잉 후 다음 report 에 합류. SPEC #178 확장: body 에 deviceId(옵션) 추가 — 주면 재생 상태가 store_device_playback_status(기기당 1행)에, 재생 로그가 play_log.device_id기기별로 기록돼 (a) 마지막 보고 기기가 앞 기기를 덮어쓰지 않고 (b) 신탁 신고 근거가 기기 수만큼 부풀지 않는다. play_log 멱등키는 partial unique 두 개(device_id IS NULL = 종전 매장 단위 · IS NOT NULL = 기기 단위)로 나뉘어 구버전 보고의 멱등이 그대로 보존된다. 생략하면 종전 매장 단위 기록 · 타 매장·회수된 기기 id 는 무시하고 매장 단위로 처리(보고 실패로 재생을 방해하지 않는다).
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))}. deviceId?(UUID, SPEC #178)즉시 송출에만 유효하다: 본인 매장의 살아있는 기기면 announcement_dispatch.target_device_id(V63)에 박혀 그 기기 1대만 pending 으로 받는다(점장 즉시방송 = “누른 그 PC 에서만”). 생략하면 종전대로 매장 전 기기 대상이라 다른 PC 들도 폴링으로 같은 방송을 받아 최대 20초씩 어긋나 반복 재생된다. 예약 송출(scheduledAt 있음)에는 무시되고(예약은 정의상 전 기기 동시 재생), 타 매장·회수된 기기 id 는 400 이 아니라 전 기기 폴백이다. isEmergency·scheduledAt 자유 조합 가능(긴급 예약 = 정당한 use case). FE 는 일반 즉시 송출이면 {} 빈 body, 그 외엔 해당 필드만 명시 전송(deviceId!isScheduled && deviceId 일 때만). 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)이 잡아 음악 위에 동시재생(더킹 + 오버레이 · SPEC #141) → 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/slot-occupancygetStoreSlotOccupancySTORE_MANAGERSPEC #109 — 점장 슬롯 점유 조회(본사 점유 5분 슬롯). query date(필수, KST YYYY-MM-DD) → 200 SlotOccupancyResponse{date, occupiedSlots: string[]}. 그 날(KST 자정~다음날 자정 [from, to))에 본인 매장에서 본사(is_hq_origin=true)가 예약(SCHEDULED) 한 슬롯 시작 시각을 5분 경계로 floor 한 UTC ISO-8601 배열로 slot_start ASC 반환(점유 0건이면 []). 점장 본인 예약(is_hq_origin=false)은 포함하지 않는다 — 점장끼리의 동일 시각 중복은 허용(SPEC #109 D4)이라, 본인 예약 표시는 listStoreScheduledBroadcasts 가 담당한다. storeId 토큰 주체 도출 → 타 매장 비노출. claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. 소스는 AnnouncementDispatchRepository.findHqOccupiedSlots. generated 훅 useGetStoreSlotOccupancy({ date })·getGetStoreSlotOccupancyQueryKey. apps/space 즉시방송 모달 §예약 송출의 5분 슬롯 그리드가 send 전에 사전 조회해 본사 점유 슬롯을 비활성 표시한다(감사 #9 — 종전에는 send 409 DISPATCH_SLOT_OCCUPIED 사후 학습이 유일한 경로라, 미리듣기 과금·수 초 대기 후에야 거절됐다). 조회 이후 새로 생긴 점유는 여전히 409 로만 알 수 있어 FE 가 사전 조회 ∪ 409 사후 보정으로 합친다.
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 미등록 또는 후보가 전부 재생 불가 URL). 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원자적 compare-and-set 으로 전진(BE #284 — 동시 폴링에서도 커서가 되감기지 않음). audioUrl 이 재생 불가 스킴(http(s) 아님)인 CM 은 커서를 전진시킨 채 건너뛰고 다음 후보를 찾는다(다음 폴링이 같은 CM 에 다시 걸리지 않게). ⚠️ 부작용 있는 GET — 호출마다 매장 CM 커서가 전진하므로 프록시 재시도·prefetch 로 CM 이 소비될 수 있다(POST 전환은 후속). 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 을 오버레이로 동시재생(SPEC #141 — 음악은 정지하지 않고 더킹된 채 계속 흐른다) + 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).
POST/api/v1/store/tickets/{id}/attachmentsuploadStoreTicketAttachmentSTORE_MANAGER첨부파일(BE PR #324) — 본인 매장 CS 티켓에 점장이 파일 첨부(화면 캡처·PDF). multipart/form-data 파트 file + query commentId? → 201 TicketAttachmentItem(bare). 제약·에러 동일(≤10MB · 5개 · MIME 5종 + 매직바이트 · 429 분당 10회). store_id 격리 404 은닉. apps/space /store/support/[id] 첨부 섹션(공유 TicketAttachments, surface="store") → 성공 시 detail invalidate. 4MB 초과는 업로드 티켓 경로(TICKET_ATTACHMENT_STORE, cross-origin XHR — 라우트 이동이 없어 재생 무영향). 업로드 진행 중에는 player 고객지원 모달의 닫기 경로를 전부 잠근다(frontend.md §18 — 두 경로 모두 같은 진행 신호를 올린다).
GET/api/v1/store/tickets/{id}/attachments/{attId}downloadStoreTicketAttachmentSTORE_MANAGER첨부 다운로드(BE PR #324) — 서버 stream. 404 TICKET_ATTACHMENT_NOT_FOUND. FE 는 downloadUrl + BFF prefix 로 같은 출처 <a download>window.location 대입 같은 네비게이션은 player 를 언마운트해 매장 음악을 끊으므로 금지.

역할별 인가 경계/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 (상세는 OPERATOR)#001 · BE #284 확장
GET/actuator/infopublic#001

storage health 컴포넌트(BE #284) — Azure Blob 연결 가능 여부를 /actuator/health 에 노출한다. 컴포넌트 상세(엔드포인트·컨테이너 등)는 OPERATOR 인증 시에만 내려가고 비인증 호출은 상태만 본다 (스토리지 토폴로지 노출 방지). storage.azure.required 플래그(기본 false)가 true 면 스토리지 장애가 전체 health 를 DOWN 으로 내리고, false 면 스토리지가 죽어도 앱 health 는 UP 을 유지한다 (음원 재생은 blob public URL 직접 재생이라 업로드만 막히는 부분 장애를 전면 장애로 확대하지 않는다).

OpenAPI

⚠️ prod·staging 에서는 Swagger UI·/v3/api-docs 가 비활성이다(BE #284 — 미인증 계약 전량 노출 차단). 따라서 /sync-api 의 스펙 소스는 배포 URL 이 아니라 로컬 bootRun 이다. 계약 갱신 흐름: BE 를 로컬에서 띄우고 BACKEND_OPENAPI_URL=http://localhost:8080/v3/api-docs pnpm sync-apipackages/api-client/openapi.json 을 커밋한다.

Path용도노출
/v3/api-docsOpenAPI JSON (orval source)local only
/swagger-ui.htmlSwagger UIlocal only
/swagger-ui/**Swagger UI assetslocal only

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 결번)