Request · Response DTOs (전체 카탈로그)
OpenAPI 자동 생성. 수기 정의 금지 ([[feedback-15]]).
Overview
이 페이지는 주요 DTO 의 reference. 실제 진실은 OpenAPI (/v3/api-docs) 와 generated TS schema
(packages/api-client/src/generated/schemas/) + orval endpoints (packages/api-client/src/generated/endpoints/).
#028 (BE v0.20.2) — backend 가 비어 있을 수 있는 필드에
@Schema(nullable=true)를 명시해 generated 타입이T | null로 바뀐 항목이 있다(아래#028 nullable표기 참조). FE 는?? undefined/?? "—"/== null가드로 처리(의미 유지). 또 operationId 유니크화로 generated 훅/Params/QueryKey 이름이 endpoint 의미를 반영한다 (Endpoints operationId 매핑 참조).
Auth DTOs
LoginRequest
{ email: string; password: string }AuthResponse
/api/v1/auth/login · /refresh · /change-password 공통 응답.
{
accessToken: string;
accessExpiresInSeconds: number; // access 토큰 수명(초, default 1800 = 30min)
refreshToken: string;
refreshExpiresAt: string; // refresh 만료 ISO 8601 절대 시각
accountId: string;
role: "OPERATOR" | "HQ_MANAGER" | "STORE_MANAGER";
passwordMustChange: boolean;
}RefreshRequest / LogoutRequest
{ refreshToken: string }MeResponse (#003 + #005 flag + #016 hqName)
{
id: string; // UUID
email: string;
name?: string;
role: Role;
hqId?: string; // HQ_MANAGER 만
storeId?: string; // STORE_MANAGER 만
passwordMustChange: boolean;
termsRequiresAgreement: boolean; // SPEC #005
privacyRequiresAgreement: boolean;
hqName?: string; // SPEC #016 — HQ_MANAGER 만 값(hqId→Hq.name). 그 외 omit
}hqName 은 실 HQ_MANAGER 가 /admin 본사 모드 셸 진입 시 본사명 표시에 쓰인다
(임퍼소네이션 경로는 sealed 세션의 hqName 사용 — MeResponse 미경유).
ChangePasswordRequest (#007)
{ currentPassword: string; newPassword: string }계정·인증 이메일 DTOs (#145)
링크(토큰 URL) 방식. token 은 메일 URL 의 ?token= 값(최대 256자, DB 엔 해시만 저장). 비밀번호는
8~100자(기존 변경 폼·ChangePasswordRequest 와 동일 정책).
// 계정 설정(setup) — SPEC #147: 약관 동의가 발급에서 이 단계로 이전
SetupVerifyRequest { token: string }
SetupVerifyResponse { email: string; role: "OPERATOR"|"HQ_MANAGER"|"STORE_MANAGER"; expiresAt: string;
activeTermsVersion?: string|null; activePrivacyPolicyVersion?: string|null } // null = OPERATOR·미게시(동의 단계 생략)
SetupCompleteRequest { token: string; newPassword: string;
termsVersion?: string|null; privacyPolicyVersion?: string|null; // @Size 32 · 활성판과 일치
signerName?: string|null } // @Size 255 · 생략 시 계정명. → 204
// 비밀번호 재설정(reset)
PasswordResetRequestRequest { email: string } // → 204 (존재 은닉)
PasswordResetConfirmRequest { token: string; newPassword: string } // → 204
// 이메일 변경
EmailChangeRequestRequest { newEmail: string } // 인증된 본인(operator/hq/store me) → 204
EmailChangeConfirmRequest { token: string } // public, 새 주소 인증 → 204발급 응답에서 계정 id 를 사용한다(setup 메일 재발송용): OperatorIssueResponse{id} ·
StoreManagerIssueResponse{storeId·managerAccountId}. 재발송은 path param
resendAccountSetup(accountId) → 204.
Email Templates (#146 — 운영사 미리보기·테스트 발송)
운영사 백오피스 이메일 템플릿 도구(OPERATOR-only). 4종 메타·미리보기·테스트 발송이 모두
실제 발송과 동일한 EmailTemplates 를 재사용한다(드리프트 0). EmailTemplateListItemType
이 4종(ACCOUNT_SETUP·PASSWORD_RESET·EMAIL_CHANGE_VERIFY·EMAIL_CHANGE_NOTICE)의 단일 소스.
type EmailTemplateListItemType =
"ACCOUNT_SETUP" | "PASSWORD_RESET" | "EMAIL_CHANGE_VERIFY" | "EMAIL_CHANGE_NOTICE"
// 목록·메타 (listEmailTemplates → EmailTemplateListItem[])
EmailTemplateListItem {
type: EmailTemplateListItemType
label: string // 한글 라벨
subject: string // 현재 발송 제목(실 발송과 동일)
placeholders: string[] // 이 종류가 렌더에 쓰는 변수명
}
// 미리보기 (previewEmailTemplate(type))
EmailTemplatePreviewResponse {
subject: string
html: string // 샘플 데이터로 렌더한 이메일-세이프 HTML 단일 문서
// — FE 가 iframe srcDoc + .force-dark 토글로 라이트/다크
}
// 테스트 발송 (testSendEmailTemplate(type))
EmailTemplateTestSendRequest { to: string } // ≤254, email 형식
EmailTemplateTestSendResponse {
delivered: boolean // 실제 발송 성공. 미설정·벤더 실패 시 false
emailConfigured: boolean // Azure 메일 인프라 설정 여부. false → '메일 미설정' 안내
}FE: apps/admin /settings/email-templates — 좌측 4종 목록 + 우측 미리보기 패널(iframe
srcDoc + 라이트/다크 토글) + 테스트 발송 폼(기본 대상 = OPERATOR me.email). emailConfigured=false
→ 미설정 안내(미발송), delivered=true → 성공 토스트, 429 → 잠시 후 재시도.
ImpersonateExchangeRequest
{ exchangeToken: string }ImpersonationExchangeResponse (#005 / #013 / #015)
/api/v1/auth/impersonate-exchange 응답. 60초·1회용 exchangeToken 을 본사 모드
access/refresh 한 쌍으로 교환한다. BFF 가 operatorEmail·hqName 을 sealed
lm_impersonation_session 에 봉인해 /admin 셸 배너를 backend 호출 없이 렌더한다.
{
accessToken: string;
accessExpiresInSeconds: number;
refreshToken: string;
refreshExpiresAt: string; // 임퍼소네이션은 60분
accountId: string; // 대상 HQ_MANAGER 계정
role: "OPERATOR" | "HQ_MANAGER" | "STORE_MANAGER";
impersonatedBy: string; // 운영자(OPERATOR) accountId
operatorEmail: string; // 배너 표시용 운영자 이메일
hqName: string; // 배너·상단바 표시용 본사명
}Terms / Privacy DTOs
LegalDocumentResponse
{
version: string; // 예 "v1.0"
content: string; // markdown (FE 가 react-markdown + rehype-sanitize 로 렌더)
publishedAt: string; // ISO instant — 게시(저장) 시각
effectiveAt: string; // ISO instant — 발효 시각. 이 시각 이후 "현재 유효본" 후보 (#033)
status: "SCHEDULED" | "ACTIVE" | "SUPERSEDED"; // 저장된 상태(게시 시점, stale 가능). 유효본 판정은 effectiveAt 기준 (#033)
}
status는 게시 시점 스냅샷이라 예약본이 발효 시각을 지나도SCHEDULED로 남을 수 있다(스케줄러 없음)./active응답은status를 항상ACTIVE로 보정한다. “현재 유효본”·온보딩 버전 검증은effectiveAt <= now중 최신으로 판정한다.
LegalDocumentHistoryResponse / LegalDocumentHistoryItem (#033)
// GET /api/v1/admin/{terms,privacy-policy}
{ items: LegalDocumentHistoryItem[] } // effectiveAt DESC, id DESC
// LegalDocumentHistoryItem — 본문(content) 제외 (목록 경량화)
{
id: string; // UUID — by-id 상세 조회 키
version: string;
publishedAt: string; // ISO instant
effectiveAt: string; // ISO instant
status: "SCHEDULED" | "ACTIVE" | "SUPERSEDED";
}본문은 GET /api/v1/admin/{terms,privacy-policy}/{id} 가 LegalDocumentResponse(content 포함)로 반환.
LegalDocumentPublishRequest (POST /api/v1/admin/terms · privacy-policy)
{
version: string; // maxLength 32. 동일 version 중복은 409
content: string; // markdown. maxLength 200000
effectiveAt?: string | null; // ISO instant. null·과거(±60s)=즉시 발효, 미래=예약. 과거(>60s)=400 (#033)
}(PrivacyPolicy 도 동일 schema)
LegalAgreeRequest (POST /api/v1/legal/terms/agree · /api/v1/legal/privacy/agree · #040)
{
version: string; // maxLength 32. 동의할 문서 버전 — 현재 유효본과 일치해야 함.
// 불일치 시 400 INVALID_LEGAL_VERSION
}재동의(SPEC #040) 요청 body. 응답은 204 No Content(멱등 — 이미 동의한 version 이면 no-op). agreedIp·agreedUserAgent·signerName·agreedAt 은 서버가 JWT principal·request 에서 자동 캡처(온보딩 동의 캡처 미러). terms·privacy 두 endpoint 가 같은 schema 를 공유한다.
Hq DTOs
HqOnboardingRequest (#010 / #013 / #147)
{
hq: {
name: string;
businessNumber?: string; // SPEC #013, ###-##-##### 패턴
plan: "AI" | "TRUST";
billingAnchorDay: number; // 1~31
llmKeywords?: string;
llmMaxPerHour: number; // 1~12
};
manager: { email: string; tempPassword: string; name: string };
}SPEC #147:
consent제거. 약관 동의는 발급이 아니라 본사 관리자 본인의 계정 설정(setup) 단계로 이전됐다(SetupCompleteRequest참조).LegalConsentPart스키마는 삭제됐다.
HqOnboardingResponse
{ hqId: string; managerAccountId: string }HqAdminListResponse (#013 §2-3 · #045)
// generated `packages/api-client/src/generated/schemas/hqAdminListResponse.ts`
{
items: HqAdminListItem[];
page: number; // 0-base 페이지 번호
size: number; // 페이지 크기 (1..100)
total: number; // 필터 적용 후 전체 건수
}#045: {items} → {items,page,size,total} envelope 로 전환(매장 #044 · 운영자 #043 미러).
FE(/hq)는 total/size 로 총 페이지·이전/다음 disabled 를 계산한다. 정렬은 status
우선순위(UNPAID → SUSPENDED → ACTIVE → ONBOARDING) → name asc → id asc 고정(페이지 간 결정성).
HqAdminListItem (#013 · #045)
// generated `packages/api-client/src/generated/schemas/hqAdminListItem.ts`
{
id: string;
name: string;
businessNumber?: string;
type: "FRANCHISE" | "INDEPENDENT"; // HqType (Store 타입과 다름 — `DIRECT` 는 StoreType)
plan?: "AI" | "TRUST";
status: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED";
storeCount: number; // #045 — 현재 페이지 hqId 집합 한정 집계
billingAnchorDay?: number;
paymentDueDays?: number | null; // #028 nullable — 미산정 시 null. generated `number | null`. FE `== null → "—"`
createdAt: string;
}AdminListHqsParams (#045 — GET /api/v1/admin/hq/admin-list query)
// generated `packages/api-client/src/generated/schemas/adminListHqsParams.ts`
{
q?: string; // 본사명·사업자번호 부분일치(대소문자 무시, max 100)
status?: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED"; // 미지정 = 가상 본사 포함 전체
type?: "FRANCHISE" | "INDEPENDENT";
plan?: "AI" | "TRUST";
page?: number; // 0-base (기본 0)
size?: number; // 1..100 clamp (기본 20)
}GET /api/v1/admin/hq/admin-list(operationId adminListHqs)의 query. #045 에서 페이지네이션을
서버사이드로 옮기며 모든 필터(q·status·type·plan)를 서버 파라미터로 이전했다(client useMemo
필터는 “현재 페이지만” 거르게 되어 페이지네이션과 모순). FE /hq 는 client-query
(useAdminListHqs(params))로 전환되어 applied/draft 필터 state·page state 를 client 에 보유한다.
HqListResponse (minimal — dropdown 용)
{ items: Array<{ id: string; name: string }> }⚠️ operationId listHqs(/api/v1/admin/hq) — 매장 생성 dropdown 전용 minimal listing.
풀 필드 어드민 목록 adminListHqs(/admin/hq/admin-list)와 별개 endpoint(검색·페이지네이션 없음).
ImpersonationIssueResponse
// generated `packages/api-client/src/generated/schemas/impersonationIssueResponse.ts`
{ exchangeToken: string; expiresInSeconds: number }SuspendRequest (#024 — suspend 요청 body)
// generated `packages/api-client/src/generated/schemas/suspendRequest.ts`
{ reason: string } // @NotBlank, @Size(max = 255)POST /api/v1/admin/hq/{id}/suspend 의 필수 body. 정지 사유(운영 감사용)를 기록한다.
backend bean validation @NotBlank(공백-only 거부) + @Size(max = 255). OpenAPI 에는 maxLength 255 만 노출되고(NotBlank 는 미표현) FE 는 trim min(1)·max(255) zod 로 미러한다(frontend.md #8).
reactivate 는 body 가 없다.
HqStatusResponse (#018 · #024 — suspend / reactivate 응답)
// generated `packages/api-client/src/generated/schemas/hqStatusResponse.ts`
{
hqId: string;
status: HqStatusResponseStatus; // ACTIVE | ONBOARDING | UNPAID | SUSPENDED
suspensionReason?: string; // #024 — SUSPENDED 일 때만 채워짐. 그 외엔 미포함(optional)
}POST /api/v1/admin/hq/{id}/suspend · /reactivate 의 200 응답. 전이 후 status 를 반환.
suspensionReason 은 generated 타입상 optional(?: string) — SUSPENDED 가 아니면 값이 없다(reactivate 시 clear → 미포함/undefined).
StoreSuspendRequest (#037 — 매장 suspend 요청 body)
// generated `packages/api-client/src/generated/schemas/storeSuspendRequest.ts`
{ reason: string } // @NotBlank, @Size(max = 255)POST /api/v1/admin/stores/{storeId}/suspend 의 필수 body. 정지 사유(운영 감사용)를 기록한다.
HQ SuspendRequest 미러 — backend @NotBlank(공백-only 거부) + @Size(max = 255). OpenAPI 에는
maxLength 255 만 노출되고(NotBlank 는 미표현) FE 는 trim min(1)·max(255) zod 로 미러한다(frontend.md #8).
reactivate 는 body 가 없다. lib/backend.ts alias = BackendStoreSuspendRequest.
StoreStatusResponse (#037 — 매장 suspend / reactivate 응답)
// generated `packages/api-client/src/generated/schemas/storeStatusResponse.ts`
{
storeId: string;
status: StoreStatusResponseStatus; // ACTIVE | SUSPENDED | INACTIVE
suspensionReason?: string | null; // SUSPENDED 직후만 채워짐. reactivate 시 null/미포함
}POST /api/v1/admin/stores/{storeId}/suspend · /reactivate 의 200 응답. 전이 후 status 를 반환.
HQ HqStatusResponse 대칭. lib/backend.ts alias = BackendStoreStatusResponse.
상세 응답 갭:
StoreDetailResponse에는 현재suspensionReason필드가 없다(HQHqDetailResponse와 비대칭 — 매장 상세 응답 DTO 미반영). FE 매장 상세 개요의 “정지 사유” 행은 SUSPENDED 일 때 노출하되 값이 없으면—로 둔다. backend 가StoreDetailResponse.suspensionReason을 추가하고 재-sync 하면 별도 FE 수정 없이 그대로 노출된다(후속).
StoreCloseRequest (#039 — 매장 폐점 요청 body)
// generated `packages/api-client/src/generated/schemas/storeCloseRequest.ts`
{ reason: string } // @NotBlank, @Size(max = 255)POST /api/v1/admin/stores/{storeId}/close 의 필수 body. 폐점 사유(운영 감사용)를 기록한다.
StoreSuspendRequest 와 동일 제약 — backend @NotBlank(공백-only 거부) + @Size(max = 255).
폐점은 비가역이라 사유가 단일 보존처(감사 로그 detail). FE 는 trim min(1)·max(255) zod 로 미러하고
“되돌릴 수 없습니다” 경고 + 2-step 확인을 둔다(frontend.md #8). lib/backend.ts alias = BackendStoreCloseRequest.
StoreCloseResponse (#039 — 매장 폐점 응답)
// generated `packages/api-client/src/generated/schemas/storeCloseResponse.ts`
{
storeId: string;
closedAt: string; // 폐점 시각 (ISO-8601). 이후 closedAt != null 이 폐점 판정
}POST /api/v1/admin/stores/{storeId}/close 의 200 응답. status 는 반환하지 않는다(폐점은 status 와
독립된 terminal 축 — closedAt 만 채움). lib/backend.ts alias = BackendStoreCloseResponse.
HqDetailResponse (#020 — GET /api/v1/admin/hq/{id})
// generated `packages/api-client/src/generated/schemas/hqDetailResponse.ts`
{
id: string;
name: string;
businessNumber?: string;
type: "FRANCHISE" | "INDEPENDENT"; // HqType
plan?: "AI" | "TRUST";
status: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED"; // HqStatus
llmAutomentEnabled: boolean;
llmKeywords?: string;
llmMaxPerHour: number;
billingAnchorDay?: number;
storeCount: number; // countGroupedByHqId 재사용 (N+1 없음)
suspensionReason?: string; // #024 — SUSPENDED 일 때만. 그 외엔 미포함(optional)
createdAt: string;
updatedAt: string;
managers: HqManagerItem[]; // role=HQ_MANAGER, findByHqIdAndRole
}/hq/{id} 상세 페이지의 개요·계정 탭 데이터. 존재하지 않는 id → 404 HQ_NOT_FOUND
(FE 가 notFound()). 매장 탭은 별도로 store admin-list ?hqId={id} 로 조회.
suspensionReason 은 status 가 SUSPENDED 일 때만 채워지며(#024), 개요 탭에 “정지 사유” 행으로
표시된다(비-SUSPENDED 면 행 생략). SPEC #123 updateHq(PATCH /api/v1/admin/hq/{id})도 이 DTO 를
read-back 으로 재사용한다(별도 응답 DTO 없음 — 본사명 갱신 후 갱신된 상세를 200 으로 반환).
UpdateHqRequest (#123 — PATCH /api/v1/admin/hq/{id} body)
// generated `packages/api-client/src/generated/schemas/updateHqRequest.ts`
UpdateHqRequest {
name: string; // 변경할 본사 이름. 비-blank · @maxLength 255 (Hq.name 생성 제약 미러)
}운영사(OPERATOR)가 본사명을 편집할 때 보내는 body(인터뷰 결정: 본사명 변경은 운영사만 — 본사
자기수정 불가). OpenAPI 에는 minLength 0·maxLength 255 로 노출되나 backend @NotBlank 가 런타임에서
공백-only 를 거부하므로, FE 는 trim 후 min(1) 로 미러(빈/blank 시 [저장] disabled). 응답은
HqDetailResponse read-back. apps/admin /hq/[id] 개요 본사명 인라인 편집 form 이 변경 시에만
(name.trim() !== 기존) 전송한다(no-op 차단). generated 훅 useUpdateHq.
HqManagerItem (#020)
// generated `packages/api-client/src/generated/schemas/hqManagerItem.ts`
{
id: string;
email: string;
name?: string;
status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // AccountStatus
passwordMustChange: boolean; // true → "변경 필요" 배지
lastLoginAt?: string | null; // #028 nullable — 미로그인 시 null. FE `?? undefined → "—"`
createdAt: string;
}HqDetailResponse.managers 항목. 본사 소속 HQ_MANAGER 계정. 계정 관리(#158)의 대상 — id 가 계정 id 로 관리 endpoint path 의 managerId 다.
HqManagerResetPasswordRequest · HqManagerSuspendRequest · HqManagerAccountResponse (#158)
// generated `packages/api-client/src/generated/schemas/hqManager{ResetPasswordRequest,SuspendRequest,AccountResponse}.ts`
HqManagerResetPasswordRequest { tempPassword: string; } // OpenAPI minLength 8, maxLength 100
HqManagerSuspendRequest { reason: string; } // OpenAPI maxLength 255 (backend 런타임 @NotBlank)
HqManagerAccountResponse {
id: string; // 본사 관리자 계정 id
status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // 전이 후 계정 상태
passwordMustChange: boolean; // 비번 재설정 시 true
}- 점장 계정 관리(#029,
StoreManager*)를 본사 관리자에 미러한 라이프사이클 계약.reset-password요청 본문 =HqManagerResetPasswordRequest, 응답 =HqManagerAccountResponse. ACTIVE 계정만 (그 외 → 409ACCOUNT_INVALID_STATUS_TRANSITION). FE zod 가 길이 제약(min 8/max 100)을 미러해 사전 reject. suspend요청 본문 =HqManagerSuspendRequest(NotBlank 는 런타임 — FE zod trim 후 min 1 미러).reactivate/resend-setup은 body 없음,revoke는 DELETE(body 없음).- 임퍼소네이션 세션에서는 전 액션 403
IMPERSONATION_FORBIDDEN_ACTION(#148). FEHqManagerManageDialog가 이 코드들을 인라인 메시지로 매핑. 감사 로그 액션HQ_MANAGER_PASSWORD_RESET·HQ_MANAGER_SUSPENDED·HQ_MANAGER_REACTIVATED·HQ_MANAGER_REVOKED신규(운영자 audit).
HqConsentsResponse (#127 — GET /api/v1/admin/hq/{id}/consents)
// generated `packages/api-client/src/generated/schemas/hqConsentsResponse.ts`
HqConsentsResponse {
hqId: string;
hqName: string;
status: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED"; // HqStatus (현재 협약 상태)
terms: ConsentRecord[]; // 이용약관 동의 이력 (동의 시각 내림차순)
privacy: ConsentRecord[]; // 개인정보처리방침 동의 이력 (동의 시각 내림차순)
termsReagreeRequired: boolean; // 현재 유효 약관 버전 > 최신 동의 → true
privacyReagreeRequired: boolean; // 현재 유효 개인정보 버전 > 최신 동의 → true
}운영사(OPERATOR)가 /contracts 에서 본사별 동의 이력·협약 상태를 조회할 때 받는 응답(SPEC #127).
기존 HqConsent(약관)·HqPrivacyConsent(개인정보)·HqStatus 를 묶어 반환 — 신규 엔티티·마이그레이션
0. 재동의 필요는 기존 유효 버전 로직 재사용. 결제(단가·청구·만료)는 v1 제외(무료 MVP). 미존재 HQ →
404 HQ_NOT_FOUND. generated 훅 useGetHqConsents. → Contracts.
ConsentRecord (#127)
// generated `packages/api-client/src/generated/schemas/consentRecord.ts`
ConsentRecord {
documentVersion: string; // 동의한 문서 버전
agreedAt: string; // 동의 시각 (ISO-8601, FE 가 KST 표기)
signerName?: string | null; // 서명자 이름 (없으면 null → FE "—")
agreedIp?: string | null; // 동의 시 IP (없으면 null → FE "—")
}HqConsentsResponse.terms·privacy 항목(AbstractConsent 필드). 동의 시각 내림차순 정렬.
Store DTOs
StoreOnboardingRequest / HqAffiliatedStoreOnboardingRequest (#011 / #147)
// 독립 매장 — POST /api/v1/admin/stores/independent
StoreOnboardingRequest {
store: {
name: string;
address?: string;
managerName?: string;
managerEmail?: string;
managerPhone?: string;
plan: "AI" | "TRUST";
billingAnchorDay: number;
};
}
// HQ 소속 매장 — POST /api/v1/admin/hq/{hqId}/stores (plan·billingAnchorDay 는 HQ 상속)
HqAffiliatedStoreOnboardingRequest {
store: {
name: string;
address?: string;
managerName?: string;
managerEmail?: string;
managerPhone?: string;
storeType: "DIRECT" | "FRANCHISE";
};
}SPEC #147: 두 발급 request 모두
consent제거. 약관 동의는 발급이 아니라 점장 본인의 계정 설정(setup) 단계로 이전됐다(SetupCompleteRequest참조).LegalConsentPart삭제.
StoreOnboardingResponse (#005/#011 · #184 확장)
// generated `schemas/storeOnboardingResponse.ts`
{
storeId: string;
// SPEC #184 D1/D2 — 매장 생성 트랜잭션에 점장 계정 생성이 편입됐다.
managerAccountId?: string | null; // 함께 생성된 STORE_MANAGER 계정 id. "점장 미정"이면 null
managerAccountCreated: boolean; // 의도적 파생 필드(= managerAccountId != null). FE 분기 가독성
setupEmailSent?: boolean | null; // 계정 미발급이면 null · 발송 성공 true · **발송 실패 false**
}store.managerEmail 이 채워지면 그 값이 곧 점장 로그인 ID 이고 같은 트랜잭션에서 계정이
생성된다(D1). 미입력이면 “점장 미정” 으로 매장만 생성(D2). 임시 비밀번호는 서버 생성이며 응답에
노출하지 않으므로(D6) 계정 설정(setup) 메일이 유일한 진입 경로다 — setupEmailSent === false
면 계정은 있는데 들어갈 방법이 없는 상태이므로 FE 는 재발송(POST /api/v1/admin/accounts/{accountId}/resend-setup)
을 안내해야 한다. 이메일 중복은 409 DUPLICATE_EMAIL 이며 매장 생성도 롤백된다(D9) —
FE 는 “매장은 등록됨” 류 안내를 띄우지 말고 이메일 필드 에러로 재제출을 유도한다.
StoreAdminListResponse (#019 · #044)
// generated `packages/api-client/src/generated/schemas/storeAdminListResponse.ts`
{
items: StoreAdminListItem[];
page: number; // 0-base 페이지 번호
size: number; // 페이지 크기 (1..100)
total: number; // 필터 적용 후 전체 건수
}#044: {items} → {items,page,size,total} envelope 로 전환(운영자 #043 미러). FE 는
total/size 로 총 페이지·이전/다음 disabled 를 계산한다.
StoreAdminListItem (#019 · #044)
// generated `packages/api-client/src/generated/schemas/storeAdminListItem.ts`
{
id: string;
name: string;
type: "INDEPENDENT" | "DIRECT" | "FRANCHISE"; // StoreType (HqType 과 다름 — `DIRECT` 는 StoreType 전용)
status: "ACTIVE" | "INACTIVE" | "SUSPENDED"; // StoreStatus
hqId: string;
hqName: string; // Hq(name) join — N+1 회피
plan?: "AI" | "TRUST";
address?: string;
managerName?: string;
billingAnchorDay?: number;
lastOnlineAt?: string | null; // #028 nullable — 미접속 시 null. FE `?? undefined → "—"` (온/오프라인 판정 v1 미포함)
closedAt?: string | null; // SPEC #044 D2 — 폐점 시각(ISO-8601). 폐점 안 했으면 null. status 미지정 = 폐점 포함 전체이므로, FE 가 행에 폐점 배지를 노출
createdAt: string;
hasManagerAccount: boolean; // SPEC #021 — 해당 매장에 role=STORE_MANAGER OperatorAccount 존재 여부(exists 집계, N+1 없음). FE 가 [계정 발급]/"발급됨" 분기
}AdminListStoresParams (#044 — GET /api/v1/admin/stores/admin-list query)
// generated `packages/api-client/src/generated/schemas/adminListStoresParams.ts`
{
q?: string; // 매장명·본사명·주소 부분일치(대소문자 무시, max 100)
status?: "ACTIVE" | "SUSPENDED" | "INACTIVE"; // 미지정 = 폐점 포함 전체
type?: "DIRECT" | "FRANCHISE" | "INDEPENDENT";
plan?: "AI" | "TRUST";
hqId?: string; // 특정 본사 매장만 스코프 (#020 — HQ 상세 매장 탭). 미지정 시 전체
page?: number; // 0-base (기본 0)
size?: number; // 1..100 clamp (기본 20)
}GET /api/v1/admin/stores/admin-list(operationId adminListStores)의 200 응답·query.
정렬: status priority(SUSPENDED → INACTIVE → ACTIVE) → name asc → id asc 고정(페이지 간
결정성). #044 에서 페이지네이션을 서버사이드로 옮기며 모든 필터(q·status·type·plan·hqId)를
서버 파라미터로 이전했다 — 부분 서버화 시 client 필터가 “현재 페이지만” 거르게 되어 깨지기
때문이다. HQ 상세 매장 탭은 { hqId, size: 100 } 로 본사 매장 전체를 확보한다(페이지네이션 UI 없음).
StoreDetailResponse (#036 — GET /api/v1/admin/stores/{id})
// generated `packages/api-client/src/generated/schemas/storeDetailResponse.ts`
{
id: string;
name: string;
type: "INDEPENDENT" | "DIRECT" | "FRANCHISE"; // StoreType
status: "ACTIVE" | "INACTIVE" | "SUSPENDED"; // StoreStatus
hqId: string;
hqName: string; // Hq(name) join — N+1 회피
plan?: "AI" | "TRUST" | null;
address?: string | null;
managerName?: string | null; // 매장 담당자(점장 연락처용)
managerEmail?: string | null;
managerPhone?: string | null;
billingAnchorDay?: number | null;
lastOnlineAt?: string | null; // ISO-8601, 미접속 시 null → FE "—"
lastHeartbeatAt?: string | null; // ISO-8601, 없으면 null → FE "—"
closedAt?: string | null; // 폐점 시각, 있으면 개요 "폐점일" danger highlight 행
createdAt: string;
updatedAt: string;
managers: StoreManagerSummaryItem[]; // role=STORE_MANAGER, SUSPENDED·WITHDRAWN 포함
}/stores/{id} 상세 페이지의 개요·점장 탭 데이터. HQ 상세 HqDetailResponse 대칭. 존재하지 않는 id → 404 STORE_NOT_FOUND(FE 가 notFound()). 결제·활동 탭은 placeholder(정산·감사로그 미구축). 점장 탭은 발급(#021)·관리(#029) 다이얼로그를 상세 컨텍스트에서 진입점으로 재사용하며, 액션 성공 시 router.refresh() 로 상세를 재검증한다. null 필드는 개요에서 ’—’ 표기.
StoreManagerSummaryItem (#036)
// generated `packages/api-client/src/generated/schemas/storeManagerSummaryItem.ts`
{
id: string;
email: string;
name?: string | null;
status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // AccountStatus
passwordMustChange: boolean; // true → "변경 필요" 배지
lastLoginAt?: string | null; // 미로그인 시 null → FE "—"
createdAt: string;
}StoreDetailResponse.managers 항목. 매장 소속 STORE_MANAGER 계정(SUSPENDED·WITHDRAWN 포함 — #029 list 일관). HQ 상세 HqManagerItem 대칭.
StoreManagerIssueRequest (#021)
// generated `packages/api-client/src/generated/schemas/storeManagerIssueRequest.ts`
{
email: string; // OpenAPI maxLength 255 (backend 런타임 @Email)
tempPassword: string; // OpenAPI minLength 8, maxLength 100 — 최초 로그인 시 변경(passwordMustChange)
name: string; // OpenAPI maxLength 255 (backend 런타임 @NotBlank)
}POST /api/v1/admin/stores/{storeId}/managers 의 요청 본문. HQ_MANAGER ManagerPart 미러. 생성 OpenAPI 스키마에는 길이 제약만 표현되고 email format·NotBlank 는 노출되지 않는다(backend 가 런타임 bean validation 으로 enforce). FE zod 검증(store-manager-issue-dialog)은 그 런타임 규칙을 미러해 사전 reject.
StoreManagerIssueResponse (#021)
// generated `packages/api-client/src/generated/schemas/storeManagerIssueResponse.ts`
{
storeId: string;
managerAccountId: string; // 생성된 OperatorAccount(role=STORE_MANAGER, storeId tenancy) id
}발급 성공 200 응답(생성 client status 200 고정). FE 는 본문을 사용하지 않고, /stores 목록에서는 콜백(onIssued → 현재 필터 useAdminListStores query invalidate, #044 — client-query 전환)·/stores/{id} 상세에서는 router.refresh() 로 hasManagerAccount 를 갱신한다. 에러: 매장 X → 404 STORE_NOT_FOUND · email 중복 → 409 DUPLICATE_EMAIL · 권한 → 403.
StoreManagerListResponse · StoreManagerListItem (#029)
// generated `schemas/storeManagerListResponse.ts` · `schemas/storeManagerListItem.ts`
StoreManagerListResponse { items: StoreManagerListItem[] } // createdAt asc, id asc
StoreManagerListItem {
id: string;
email: string;
name?: string | null; // 없으면 null
status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // StoreManagerListItemStatus
passwordMustChange: boolean; // 임시 비번 상태(첫 로그인 변경 강제)
lastLoginAt?: string | null; // ISO-8601, 없으면 null
createdAt: string; // ISO-8601
}GET /api/v1/admin/stores/{storeId}/managers 의 200 응답. FE 관리 다이얼로그(store-manager-manage-dialog)가 계정 카드로 렌더 — lastLoginAt/createdAt 은 KST(Intl Asia/Seoul) 표기.
ResetPasswordRequest · StoreManagerSuspendRequest · StoreManagerAccountResponse (#029)
// generated `schemas/resetPasswordRequest.ts` · `storeManagerSuspendRequest.ts` · `storeManagerAccountResponse.ts`
ResetPasswordRequest { tempPassword: string; } // OpenAPI minLength 8, maxLength 100
StoreManagerSuspendRequest { reason: string; } // OpenAPI maxLength 255 (backend 런타임 @NotBlank)
StoreManagerAccountResponse {
id: string;
status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // 전이 후 상태
passwordMustChange: boolean; // 비번 재설정 시 true
}reset-password요청 본문 =ResetPasswordRequest, 응답 =StoreManagerAccountResponse. ACTIVE 계정만 (그 외 → 409ACCOUNT_INVALID_STATUS_TRANSITION). FE zod 가 길이 제약(min 8/max 100)을 미러해 사전 reject.suspend요청 본문 =StoreManagerSuspendRequest. OpenAPI 에는 길이만 노출되고 NotBlank 는 런타임 — FE zod 가 trim 후 min 1 로 미러.reactivate(body 없음)·revoke(DELETE, body 없음)는 상태 전이만. 잘못된 전이 → 409ACCOUNT_INVALID_STATUS_TRANSITION· 미존재 → 404STORE_MANAGER_NOT_FOUND/STORE_NOT_FOUND.
HQ Mode / Store Mode DTOs (#049 — 본사·점장 본인 프로필)
본사(HQ_MANAGER)·매장(STORE_MANAGER) 본인이 자기 소속을 조회하는 me 응답(#049). 운영사(OPERATOR)의
어드민 상세(HqDetailResponse·StoreDetailResponse)와 달리 본인 한정(타 테넌트 조회 불가)이며, service 가
PrincipalScopeGuard 로 검증한 DB 기준 hqId/storeId 로만 조회한다(claim 비신뢰 — Auth Model).
HqMeResponse (#049 — GET /api/v1/hq/me)
// generated `packages/api-client/src/generated/schemas/hqMeResponse.ts`
{
id: string; // 본사 id (UUID)
name: string; // 본사명
type: "FRANCHISE" | "INDEPENDENT"; // HqMeResponseType — HqType (Store 타입과 다름)
plan?: "AI" | "TRUST" | null; // @nullable — 요금제 없으면 null
status: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED"; // HqMeResponseStatus
businessNumber?: string | null; // @nullable — 사업자등록번호(NNN-NN-NNNNN), 없으면 null
billingAnchorDay?: number | null; // @nullable — 청구 기준일(1~31), 없으면 null
storeCount: number; // 소속 매장 수
commercialCycleSongs: number; // SPEC #095 — 본사 CM송 사이클 빈도(N곡마다 1회). V32 default 5 · 1~100
duckEnabled: boolean; // 더킹 — 멘트 중 배경음악 감쇠 사용 여부 default. BE 신규 default true
duckVolumePercent: number; // 더킹 목표 볼륨(정상 대비 %, 0~100). BE 신규 default 20
duckFadeMs: number; // 더킹 fade in/out 시간(ms, 0~5000). BE 신규 default 400
createdAt: string; // 본사 생성 시각 (ISO-8601)
}operationId getHqMe. 현재 인증된 HQ_MANAGER 의 소속 본사 요약. claim↔DB 불일치·비활성(SUSPENDED·WITHDRAWN)·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 본사 데이터 미존재 404. generated 훅 useGetHqMe. plan enum 은 HqMeResponsePlan(AI·TRUST, nullable), type 은 HqMeResponseType, status 는 HqMeResponseStatus.
UpdateHqMeRequest (#085 — PATCH /api/v1/hq/me)
// generated `packages/api-client/src/generated/schemas/updateHqMeRequest.ts`
{
/** 본사 매니저 본인 이름. 생략 또는 null = 미변경. non-null 이면 1~50자. */
name?: string | null; // @minLength 1 @maxLength 50 @nullable
/** SPEC #095 — 본사 CM송 사이클 빈도(N곡마다 1회 삽입). 생략 또는 null = 미변경. non-null 이면 1~100. */
commercialCycleSongs?: number | null; // @minimum 1 @maximum 100 @nullable
}operationId updateHqMe. 현재 인증된 HQ_MANAGER 의 본인 매니저 계정 이름(name) + 본사 CM 사이클
빈도(commercialCycleSongs, SPEC #095) 를 편집한다(부분 업데이트). null/생략 = 미변경. 응답은
HqMeResponse(GET 과 동일 DTO 재사용 — 본사 요약을 그대로 반환).
email 변경 금지·비밀번호 변경 금지(별도 endpoint /onboarding/change-password SPEC #003).
본사명·다중 매니저 관리는 운영자 영역(F1·F4 후속). claim↔DB 불일치·비활성·role/소속 불일치 → 403
PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 빈/blank name 400 · commercialCycleSongs 범위 밖 400.
generated 훅 useUpdateHqMe.
HqMeResponse.name은 본사명이고UpdateHqMeRequest.name은 본인 매니저 본인 이름 — 의미가 다르다.apps/space/admin/settings본인 매니저 이름 편집 form 은 me 응답을 입력 초기값에 쓰지 않고 빈 문자열에서 시작한다(혼동 회피). 본인 매니저 name read 는 F 후속 슬라이스(me 확장).
UpdateHqDuckingRequest (더킹 default — PATCH /api/v1/hq/me/ducking)
// generated `packages/api-client/src/generated/schemas/updateHqDuckingRequest.ts`
UpdateHqDuckingRequest {
duckEnabled: boolean; // 필수
duckVolumePercent: number; // 필수 · @minimum 0 @maximum 100
duckFadeMs: number; // 필수 · @minimum 0 @maximum 5000
}operationId updateHqDucking. HQ_MANAGER-only. 현재 인증된 본사의 더킹 default 를 편집한다 — 멘트
(안내방송·CM) 재생 동안 배경음악을 정지하지 않고 볼륨만 감쇠해 동시 재생하는 동작의 산하 매장 기본값.
응답 200 HqMeResponse(GET 과 동일 DTO 재사용 — duck* 가 노출돼 FE 가 새 값 read-back). 매장별
override 는 매장 상세에서(updateStoreDucking). ⚠️ 부분 PATCH 가 아니라 3필드 전체 set(전체
replace) — 본사 default 는 항상 세 값이 존재하므로 한 필드만 바꿔도 세 값을 모두 전송한다. CM 사이클
빈도(#095)와 같은 본사 default + 매장 override 위계. 검증: duckVolumePercent 0100,
5000(BE invariant duckFadeMs 0Hq.kt). 범위 밖 400 · claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH
· 미인증 401. generated 훅 useUpdateHqDucking. apps/space /admin/settings 더킹 default 섹션이
사용 토글 + 볼륨%(0100) + fade ms(05000) + [저장] 으로 소비(off 면 멘트 중 음악 정지 = 종전 동작).
StoreMeResponse (#049 — GET /api/v1/store/me)
// generated `packages/api-client/src/generated/schemas/storeMeResponse.ts`
{
id: string; // 매장 id (UUID)
name: string; // 매장명
type: "DIRECT" | "FRANCHISE" | "INDEPENDENT"; // StoreMeResponseType — StoreType (HqType 과 다름)
status: "ACTIVE" | "SUSPENDED" | "INACTIVE"; // StoreMeResponseStatus
hqId: string; // 소속 본사 id (UUID)
hqName: string; // 소속 본사명 (Hq(name) join)
plan?: "AI" | "TRUST" | null; // @nullable — 요금제 없으면 null
address?: string | null; // @nullable — 매장 주소, 없으면 null
billingAnchorDay?: number | null; // @nullable — 청구 기준일(1~31), 없으면 null
hqCommercialCycleSongs: number; // SPEC #095 — 소속 본사 CM송 사이클 빈도 default(N곡마다 1회, transparency 노출)
storeCommercialCycleSongs: number | null; // SPEC #103 — 매장 override (null=없음, 1..100=적용). 본사가 매장 상세에서 편집
commercialCycleSongs: number; // SPEC #103 — **effective 값**(매장 override ?? HQ default). 점장 player 가 직접 소비
duckEnabled: boolean; // 더킹 — **effective 값**(매장 override ?? HQ default). 점장 player 가 직접 소비
duckVolumePercent: number; // 더킹 목표 볼륨(%) effective(0~100). 매장 override ?? HQ default
duckFadeMs: number; // 더킹 fade 시간(ms) effective(0~5000). 매장 override ?? HQ default
createdAt: string; // 매장 생성 시각 (ISO-8601)
}operationId getStoreMe. 현재 인증된 STORE_MANAGER 의 소속 매장 요약. HQ HqMeResponse 대칭이되 소속 본사(id·name)를 포함한다. claim↔DB 불일치·비활성·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 매장 데이터 미존재 404. generated 훅 useGetStoreMe. plan enum 은 StoreMeResponsePlan(nullable), type 은 StoreMeResponseType, status 는 StoreMeResponseStatus.
HQ
HqMeResponse와 비대칭 — 매장 me 에는businessNumber가 없고(매장 단위 미보유) 대신 소속 본사(hqId·hqName)를 포함한다.
UpdateStoreMeRequest (#087 — PATCH /api/v1/store/me)
// generated `packages/api-client/src/generated/schemas/updateStoreMeRequest.ts`
{
/** 점장 매니저 본인 이름. 생략 또는 null = 미변경. non-null 이면 1~50자. */
name?: string | null; // @minLength 1 @maxLength 50 @nullable
}operationId updateStoreMe. 현재 인증된 STORE_MANAGER 의 본인 매니저 계정 이름(name) 만 편집한다
(부분 업데이트). null/생략 = 미변경. 응답은 StoreMeResponse(GET 과 동일 DTO 재사용 — 매장 요약을 그대로
반환). email 변경 금지·비밀번호 변경 금지(별도 endpoint /onboarding/change-password SPEC #003).
매장 정보 편집은 운영사/본사 영역(F1 후속). claim↔DB 불일치·비활성·role/소속 불일치 → 403
PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 빈/blank name 400. generated 훅 useUpdateStoreMe. 본사 #085
UpdateHqMeRequest 와 동형 시그니처.
StoreMeResponse.name은 매장명이고UpdateStoreMeRequest.name은 본인 매니저 본인 이름 — 의미가 다르다(본사 #085 와 동일 패턴).apps/space/store/profile본인 매니저 이름 편집 form 은 me 응답을 입력 초기값에 쓰지 않고 빈 문자열에서 시작한다(혼동 회피). 본인 매니저 name read 는 F 후속 슬라이스(me 확장).
StoreOwnPlaylistListResponse / StoreOwnPlaylistListItemResponse / ListStorePlaylistsParams (#129 — GET /api/v1/store/playlists)
// generated `packages/api-client/src/generated/schemas/storeOwnPlaylistListResponse.ts`
StoreOwnPlaylistListResponse {
items: StoreOwnPlaylistListItemResponse[]; // 선택 가능 PL 목록(자기 본사 활성 PL)
page: number; // 0-base
size: number;
total: number;
}
// storeOwnPlaylistListItemResponse.ts
StoreOwnPlaylistListItemResponse {
id: string;
name: string;
isDefault: boolean; // 본사 기본 PL 여부(활성 미선택 시 점장 큐 fallback 대상)
libraryCount: number; // 담긴 활성 라이브러리 수
status: "EMPTY" | "UNUSED" | "ACTIVE" | "FALLBACK"; // 파생 상태(응답 계산값). 배타 우선순위 EMPTY>FALLBACK>ACTIVE>UNUSED
active: boolean; // 현재 이 매장의 활성 PL 인지(현재 선택 표시용)
updatedAt: string; // ISO-8601
}
// listStorePlaylistsParams.ts — query
ListStorePlaylistsParams { q?: string; page?: number; size?: number; }operationId listStorePlaylists. 점장(STORE_MANAGER)이 활성 PL 로 선택할 수 있는 본인 매장 소속 본사
PL 목록(SPEC #129). 경로/쿼리에 hqId·storeId 없음 — 토큰 claim DB 재검증으로 본인 본사 PL 로만 스코프.
정렬 updated_at DESC. status 는 저장 아닌 응답 계산값(라이브러리 0=EMPTY·본사 기본=FALLBACK·적용
매장 ≥1=ACTIVE·그 외=UNUSED). generated 훅 useListStorePlaylists. → Store Active Playlist.
SetStoreOwnActivePlaylistRequest (#129 — PATCH /api/v1/store/me/active-playlist body)
// generated `packages/api-client/src/generated/schemas/setStoreOwnActivePlaylistRequest.ts`
SetStoreOwnActivePlaylistRequest {
playlistId?: string | null; // 적용할 PL id(자기 본사 활성 PL). null = 활성 해제(본사 기본 PL fallback)
}점장이 본인 매장 활성 PL 을 선택(non-null)하거나 해제(null=본사 기본 fallback)할 때 보내는 body(SPEC
#129). 타 본사·미존재·삭제 PL → 404 PLAYLIST_NOT_FOUND(존재 은닉). plan 위반 PL 선택해도 차단 안 함
(큐 빌드 #122 가 필터). 응답은 아래 StoreActivePlaylistResponse(적용 PL 요약, 운영사 #055 와 공유 DTO).
실제 변경 시 매장 감사 1건 STORE_ACTIVE_PLAYLIST_CHANGED(#114). generated 훅
useSetStoreOwnActivePlaylist.
StoreActivePlaylistResponse (#055 도입 · #129 점장 self 경로 공유)
// generated `packages/api-client/src/generated/schemas/storeActivePlaylistResponse.ts`
StoreActivePlaylistResponse {
active: boolean; // 활성 PL 적용 여부 (미적용 시 false)
playlistId?: string | null; // 적용된 PL id (미적용 시 null)
name?: string | null; // 적용된 PL 이름 (미적용 시 null)
libraryCount?: number | null; // 담긴 활성 라이브러리 수 (미적용 시 null)
appliedAt?: string | null; // 활성 PL 을 적용한 시각 (미적용 시 null, ISO-8601) — 전용 컬럼(V52)
}매장 활성 PL 요약. 운영사 getStoreActivePlaylist/setStoreActivePlaylist(#055)와 점장
setStoreOwnActivePlaylist(#129)가 공유하는 응답 DTO. 미적용(본사 기본 fallback) 이면 active=false·
나머지 null.
StoreScheduleResponse / StoreScheduleEntry / ScheduleLibraryOption (#171 — GET /api/v1/store/me/schedule)
// generated `packages/api-client/src/generated/schemas/storeScheduleResponse.ts`
StoreScheduleResponse {
playlistId?: string | null; // 적용 대상 PL id (활성 PL 없으면 null)
playlistName?: string | null; // 적용 대상 PL 이름(표시용, 활성 PL 없으면 null)
isOverride: boolean; // true=매장 맞춤(override) · false=본사 기본 표시
entries: StoreScheduleEntry[]; // 시간표 구간(startMinute 오름차순, 비면 전체 셔플)
availableLibraries: ScheduleLibraryOption[]; // 편집 팔레트(활성 PL 멤버, position 순)
}
StoreScheduleEntry {
startMinute: number; // 구간 시작(하루 중 분, KST, 30분 배수)
endMinute: number; // 구간 종료(반열림 [start,end), 30분 배수)
libraryId: string; // 재생 라이브러리 id
libraryName?: string | null; // 표시용 이름(없으면 null — stale entry 는 null)
}
ScheduleLibraryOption {
id: string; // 라이브러리 id
name: string; // 표시용 이름
}
// generated `schemas/getStoreScheduleParams.ts`·`customizeStoreScheduleParams.ts`
// ·`setStoreScheduleParams.ts`·`deleteStoreScheduleParams.ts` — 4종 동일 shape (SPEC #178)
GetStoreScheduleParams {
deviceId?: string; // 이 요청을 보내는 기기 id. 주면 큐와 **동일한 3단 해석**(기기 지정 PL → 매장 활성 PL → 본사 기본 PL, 공용 `StorePlaylistResolver`)으로 편집 대상 PL 을 정한다. 생략하면 종전 2단(매장 활성 → 본사 기본) — 기기 지정 PL 로 재생 중인 PC 에서는 저장이 200 이어도 **음악이 안 바뀌는 조용한 no-op**. 타 매장·회수된 기기 id 는 400 이 아니라 조용한 폴백.
}점장 시간대별 시간표(SPEC #171 FE-2, 기능 문서).
override 가 있으면 그 구간 + isOverride=true, 없으면 본사 기본 + isOverride=false. 대상 PL 없으면
playlistId=null·playlistName=null·entries=[]. SPEC #178 — deviceId query 는 4종
(getStoreSchedule·customizeStoreSchedule·setStoreSchedule·deleteStoreSchedule) 모두에 있다(각각
GetStoreScheduleParams·CustomizeStoreScheduleParams·SetStoreScheduleParams·DeleteStoreScheduleParams
— shape 동일). FE 는 기기가 등록돼 있으면 조회·시딩·저장·해제 전부에 싣는다. playlistName 은 편집 대상 식별용 — 시간표는
활성 PL 단위라 PL 이 바뀌면 편집 대상도 바뀌는데, 화면만 보고는 어느 PL 의 시간표인지 알 수 없었다. availableLibraries = 활성 PL 에 담긴 라이브러리 전체(그리드 브러시
후보). ⚠️ stale 경계: entries 의 libraryId 는 쓰기 시점에만 availableLibraries 부분집합이
보장된다 — 쓰기 후 라이브러리가 PL 에서 제거/soft-delete 되면 entries ⊄ availableLibraries 가 될 수
있다(그때 libraryName=null). FE 는 그런 stale entry 를 회색 “삭제된 라이브러리” 로 방어 렌더하고 저장
시 걸러낸다.
SetStoreScheduleRequest / ScheduleEntryRequest (#171 — PUT /api/v1/store/me/schedule)
// generated `packages/api-client/src/generated/schemas/setStoreScheduleRequest.ts`
SetStoreScheduleRequest {
entries: ScheduleEntryRequest[]; // 전체 교체(1~48개, @minItems 1 · @maxItems 48)
}
ScheduleEntryRequest {
startMinute: number; // 0~1410, 30분 배수 (KST)
endMinute: number; // 30~1440, 30분 배수, start<end, 반열림 [start,end)
libraryId: string; // 해당 PL 의 멤버여야 함
}매장 override 시간표 전체 교체 요청. @minItems 1 이라 빈 배열 불가(하루 전체 셔플은 DELETE
되돌리기로). FE 클라이언트 zod/가드는 이 제약(30분 배수·[0,1440]·팔레트 멤버)과 일치시킨다. 400
SCHEDULE_INVALID_BOUNDS·400 SCHEDULE_LIBRARY_NOT_MEMBER·409 SCHEDULE_TIME_OVERLAP.
PlaylistScheduleResponse / PlaylistScheduleEntry (#171 — GET·PUT /api/v1/admin/playlists/{id}/schedule · GET·PUT /api/v1/hq/playlists/{id}/schedule)
// generated `packages/api-client/src/generated/schemas/playlistScheduleResponse.ts`
PlaylistScheduleResponse {
playlistId: string; // 대상 PL id
entries: PlaylistScheduleEntry[]; // 시간표 구간(startMinute 오름차순, 비면 전체 셔플)
}
PlaylistScheduleEntry {
startMinute: number; // 구간 시작(하루 중 분, KST, 30분 배수)
endMinute: number; // 구간 종료(반열림 [start,end), 30분 배수)
libraryId: string; // 재생 라이브러리 id
libraryName?: string | null; // 표시용 이름(없으면 null — stale entry 는 null)
}PL 기본 시간표(운영사가 정하는 본사 기본). 운영사 getPlaylistSchedule(OPERATOR 편집)과 본사
getHqPlaylistSchedule(HQ_MANAGER 편집)이 동일 DTO 를 공유한다. 매장 시간표(StoreScheduleResponse)와
달리 availableLibraries 를 응답에 싣지 않는다 — 편집 팔레트는 운영사 화면이 이미 조회하는 담긴 라이브러리
(listPlaylistLibraries)를, 본사 화면은 상세 libraries(+ entries.libraryName)를 라벨 소스로 쓴다.
stale 경계는 매장과 동일(쓰기 후 라이브러리 제거 시 entries ⊄ 팔레트, libraryName=null → 회색 방어 렌더).
SetPlaylistScheduleRequest (#171 — PUT /api/v1/admin/playlists/{id}/schedule · PUT /api/v1/hq/playlists/{id}/schedule)
// generated `packages/api-client/src/generated/schemas/setPlaylistScheduleRequest.ts`
SetPlaylistScheduleRequest {
entries: ScheduleEntryRequest[]; // 전체 교체(@minItems 0 · @maxItems 48)
}
// ScheduleEntryRequest 는 매장 시간표와 공유(위 참조).PL 기본 시간표 전체 교체 요청. 매장(SetStoreScheduleRequest, @minItems 1)과 달리 @minItems 0 — 빈
배열 허용 = 시간표 해제(전 시간대 전체 셔플로 회귀). 운영사 setPlaylistSchedule(OPERATOR)와 본사
setHqPlaylistSchedule(HQ_MANAGER)이 동일 DTO·동작으로 같은 본사 기본 시간표를 수정한다(매장 조정은 점장
소관). 400 SCHEDULE_INVALID_BOUNDS·400 SCHEDULE_LIBRARY_NOT_MEMBER·409 SCHEDULE_TIME_OVERLAP·404
PLAYLIST_NOT_FOUND.
Store Device DTOs (#178 — /api/v1/store/devices*)
한 매장에서 PC 여러 대가 같은 점장 계정으로 음악을 트는 현실을 담는 층(SPEC #178). 계정으로는 기기를
구분할 수 없으므로 클라이언트가 발급해 localStorage 에 두는 UUID(deviceKey) 를 매장 스코프에서 등록해
식별하고, 그 위에 기기별 활성 PL·재생 상태·재생 로그·방송 ack 를 얹는다. 화면은
Store Devices 참조.
RegisterStoreDeviceRequest / StoreDeviceResponse / StoreDeviceListResponse (#178 — POST·GET /api/v1/store/devices)
// generated `schemas/registerStoreDeviceRequest.ts`·`storeDeviceResponse.ts`·`storeDeviceListResponse.ts`
RegisterStoreDeviceRequest {
deviceKey: string; // 클라 발급 기기 식별자(UUID 권장, @maxLength 64 · non-blank). 같은 값 재호출 = 멱등
label?: string | null; // 기기 이름(@maxLength 50). 생략하면 서버가 "기기 N" 으로 채움
}
StoreDeviceResponse {
id: string; // 서버 발급 기기 id — 이후 요청의 deviceId
label: string; // 기기 이름
lastSeenAt: string; // 마지막 신호 시각(등록·재생 보고 시 갱신, ISO-8601)
createdAt: string; // 등록 시각(ISO-8601)
}
StoreDeviceListResponse {
items: StoreDeviceResponse[]; // 등록 순(created_at), 회수된 기기 제외. 상한 4대라 페이지네이션 없음
}
deviceKey는 응답에 담기지 않는다 — 클라이언트가 이미 갖고 있고 노출 이득이 없다. 등록은 멱등(같은(storeId, deviceKey)면 기존 행 반환 +lastSeenAt갱신)이라 새로고침·재부팅으로 기기가 늘지 않는다. 상한 4대 초과는 409DEVICE_LIMIT_EXCEEDED— 서버 자동 회수는 없고 점장이 목록에서 정리한다(오프라인과 폐기를 구분할 수 없어 자동 회수는 재생 중인 기기를 뺏는다).lastSeenAt은 그 정리 판단의 유일한 근거다.
RenameStoreDeviceRequest (#178 — PATCH /api/v1/store/devices/{id} body)
// generated `schemas/renameStoreDeviceRequest.ts`
RenameStoreDeviceRequest {
label: string; // 새 기기 이름(1~50자 · non-blank)
}미존재·이미 회수됨·타 매장 기기는 전부 404
STORE_DEVICE_NOT_FOUND(존재 은닉). 회수 (DELETE .../{id})는 204·soft-delete 이며 같은 404 시맨틱을 공유한다.
StoreDevicePlaylistResponse / SetStoreDevicePlaylistRequest (#178 — GET·PATCH /api/v1/store/devices/{id}/active-playlist)
// generated `schemas/storeDevicePlaylistResponse.ts`·`setStoreDevicePlaylistRequest.ts`
StoreDevicePlaylistResponse {
deviceId: string; // 기기 id
playlistId?: string | null; // 지정된 PL id. null = 지정 없음 → 매장 기본(매장 활성 PL → 본사 기본 PL)
appliedAt?: string | null; // 지정 시각(지정 이력이 없으면 null)
}
SetStoreDevicePlaylistRequest {
playlistId?: string | null; // 이 기기가 재생할 PL. null = 지정 해제(매장 기본으로 복귀)
}같은 매장이라도 기기마다 다른 목록을 튼다(D3 — 종전엔 활성 PL 이
store.active_playlist_id단일 값이라 한 PC 에서 바꾸면 다른 PC 도 따라갔다). 자기 본사 PL 이 아니거나 삭제된 PL 이면 404PLAYLIST_NOT_FOUND(존재 은닉) · 기기 미존재·회수됨·타 매장은 404STORE_DEVICE_NOT_FOUND. 반영은 다음 큐 조회부터이며, 큐 해석 우선순위는StorePlaybackQueueResponse.source참조.store.active_playlist_id는 그대로 유지된다(D13 expand→migrate→contract — 구버전 클라이언트와 기기 지정이 없는 기기의 폴백 소스).
HQ Mode 대시보드·산하 매장 DTOs (#051 — apps/space /admin·/admin/stores)
본사(HQ_MANAGER) 본인이 자기 산하 매장 현황·목록을 보는 read 응답(#051). getHqMe(#049)와 동일하게
PrincipalScopeGuard 로 확정한 본인 hqId 스코프로만 조회한다(요청에 hqId 파라미터 없음 — 타 본사 미노출).
HqDashboardResponse (#051 — GET /api/v1/hq/dashboard)
// generated `packages/api-client/src/generated/schemas/hqDashboardResponse.ts`
{
totalStores: number; // 산하 매장 총수 (ACTIVE+INACTIVE+SUSPENDED, 폐점 포함)
activeStores: number; // ACTIVE 매장 수
inactiveStores: number; // INACTIVE 매장 수
suspendedStores: number; // SUSPENDED 매장 수
closedStores: number; // 폐점(closedAt 설정) 매장 수
}operationId getHqDashboard. 산하 매장 상태별 집계. 송출·정산 지표는 방송·billing 도메인 미도착이라 미포함(후속 §F1).
claim↔DB 불일치·비활성·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqDashboard.
apps/space /admin 대시보드가 server component 에서 refresh-aware 로 소비 — 산하 매장(총수+상태별 배지)·폐점 카드. 정산 카드는 “준비 중” placeholder. “오늘 송출 방송” placeholder 는 SPEC #119 F3 에서 실데이터 “오늘 송출 미도달 매장” 카드(아래 HqUndeliveredTodayResponse, client island)로 교체했다.
HqUndeliveredTodayResponse (#119 F3 — GET /api/v1/hq/dashboard/undelivered-today)
// generated `packages/api-client/src/generated/schemas/hqUndeliveredTodayResponse.ts`
{
undeliveredStoreCount: number; // 오늘(KST) 본사 송출 미도달(PENDING·MISSED) distinct 매장 수
}operationId getHqUndeliveredToday(D4). 집계 조건은 오늘(KST) 윈도우 · status IN (PENDING, MISSED) · is_hq_origin = true 다(감사 #11·#11-a):
- MISSED 포함 — MISSED 는 “들리지 않은 채 종결”(grace 초과 자동 만료 · 점장 player 의 재생 실패·폐기 보고)이라 미도달이다. PENDING 만 세면 실패 ack 로 MISSED 가 된 송출이 카운트에서 빠져 “모두 도달” 로 보이는 역전이 생긴다.
- 점장 직접 송출 제외 — 점장 즉시방송·점장 예약은 같은 hq 버킷에 들어가지만 본사 송출이 아니다. 이 카드는 본사 송출의 도달률 지표다.
- PLAYED(실제 재생)·SCHEDULED(미도래)·CANCELED(본사 취소)는 제외.
dispatch 는 미ack 시 영구 PENDING 이라 전체 카운트는 노이즈 누적 → 오늘(KST) 윈도우로 한정 집계(영구 PENDING 누적 회피). hqId 토큰 주체 도출(타 본사 미집계). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqUndeliveredToday.
apps/space /admin 대시보드 “오늘 송출 미도달 매장” 카드(UndeliveredTodayCard client island)가 60초 폴링(refetchInterval:60s·refetchIntervalInBackground:false)으로 소비 — count>0 면 danger 강조(카드를 버튼화 → 클릭 시 아래 HqUndeliveredStoreListResponse 드릴다운 다이얼로그, SPEC #162), 0 이면 “모두 도달” 비강조·비링크(false affordance 제거).
HqUndeliveredStoreListResponse (#162 H11 — GET /api/v1/hq/dashboard/undelivered-today/stores)
// generated `packages/api-client/src/generated/schemas/hqUndeliveredStoreListResponse.ts`
{
items: HqUndeliveredStoreItem[]; // 본사 송출 미도달(PENDING·MISSED) distinct 매장(매장명 오름차순)
total: number; // count endpoint 값과 정합
}
// generated `packages/api-client/src/generated/schemas/hqUndeliveredStoreItem.ts`
interface HqUndeliveredStoreItem {
storeId: string; // 매장 상세 라우팅용(/admin/stores/{storeId})
storeName: string; // 매장명
}operationId getHqUndeliveredTodayStores(#162 H11). count 카드(getHqUndeliveredToday)의 드릴다운 — getHqUndeliveredToday 와 완전히 동일한 조건(오늘 KST 윈도우 · status IN (PENDING, MISSED) · is_hq_origin = true · hqId 격리)에 Store 조인으로 매장명을 붙인 distinct store 목록(한 매장 여러 미도달=1행). 두 endpoint 가 같은 술어를 쓰므로 total == count 값이 항상 정합하다 — 한쪽만 필터를 바꾸면 카드 숫자와 목록 길이가 어긋난다(감사 #11-a). 매장명 오름차순(동명 시 storeId 타이브레이크). query page(0-base)·size(1..100 clamp, 기본 20). hqId 토큰 주체 도출(타 본사 미반영). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqUndeliveredTodayStores.
apps/space /admin 대시보드 미도달 카드 클릭 시 열리는 UndeliveredTodayDialog(apps/space/src/app/admin/undelivered-today-dialog.tsx, enabled: open 로 열릴 때만 조회)가 소비 — 로딩 스피너·에러 ErrorState(SPEC #154, 재시도=refetch)·빈 상태·행별 매장 상세 링크·페이지네이션(total > size). 카드가 /admin/audit(delivery 필터 없는 송출 행위 로그)로 보내 “어느 매장인지” 볼 수 없던 dead-end(감사 H11) 를 해소한다.
HqPlaybackStatusResponse / HqPlaybackStatusStoreItem (#172 FE-B — GET /api/v1/hq/dashboard/playback-status)
// generated `packages/api-client/src/generated/schemas/hqPlaybackStatusResponse.ts`
{
playingCount: number; // 지금 재생 중(PLAYING) 매장 수
silentCount: number; // 무음(SILENT) 매장 수
offlineCount: number; // 신호 끊김(OFFLINE, 서버 파생) 매장 수
total: number; // 산하 미폐점 매장 전체 수. ⚠️ PAUSED 는 세 카운트 밖 → playing+silent+offline != total 가능(합계로 쓰지 말 것)
items: HqPlaybackStatusStoreItem[]; // 매장별 상태(매장명 오름차순)
}
// generated `packages/api-client/src/generated/schemas/hqPlaybackStatusStoreItem.ts`
interface HqPlaybackStatusStoreItem {
storeId: string; // 매장 상세 라우팅용(/admin/stores/{storeId})
storeName: string; // 매장명
state: HqPlaybackStatusStoreItemState; // PLAYING·PAUSED·SILENT·OFFLINE(OFFLINE 만 서버 파생)
lastSeenAt?: string | null; // 마지막 heartbeat 시각. 한 번도 보고 안 한 매장은 null
currentMusicId?: string | null; // 현재 재생 곡 id. OFFLINE·무음이면 null
currentMusicTitle?: string | null; // 현재 재생 곡 제목. OFFLINE·무음·삭제 곡이면 null
}operationId getHqPlaybackStatus(#172 FE-B). 미도달(방송 도달률)과 별개인 음악 재생 축 — “산하 매장이 지금 음악을 틀고 있나”를 답한다. OFFLINE 은 서버가 마지막 heartbeat staleness 로 파생(점장 player 는 보고하지 않는 값 — PLAYING/PAUSED/SILENT 만 명시 보고). SPEC #178 — 소스가 기기 단위 우선으로 바뀌었다: 기기 행(store_device_playback_status)이 하나라도 있는 매장은 그 기기들을 PLAYING > PAUSED > SILENT > OFFLINE 우선순위로 접고(“하나라도 재생 중이면 그 매장은 재생 중” — 대시보드의 목적이 “음악이 나가고 있는가” 판단이라 가장 활동적인 상태가 매장을 대표해야 한다), 기기 행이 없는 매장만 종전 store_playback_status(매장당 1행)로 폴백한다. 회수된 기기의 유령 행은 store_device 조인 + deleted_at IS NULL 로 제외. 각 기기는 먼저 개별 staleness 로 OFFLINE 파생을 거치므로 꺼진 PC 한 대가 매장 전체를 OFFLINE 으로 끌어내리지 않고 stale 한 PC 가 PLAYING 을 위조하지도 않는다. lastSeenAt = 기기들 중 가장 최근 신호 · 현재 곡 = 그 상태를 결정한 기기 중 첫 행(repository 정렬이 last_heartbeat_at DESC 라 결정적 · 곡이 없는 기기가 앞서면 곡이 있는 같은 상태의 기기를 우선 · OFFLINE 이면 감춤). 종전엔 매장 행 PK 가 store_id 라 마지막에 보고한 기기가 앞 기기를 덮어써 새로고침마다 상태·현재 곡이 왕복했다(V59 가 “조회 병합은 후속”으로 남겨둔 expand 단계를 닫은 변경). hqId 토큰 주체 도출(타 본사 0). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqPlaybackStatus.
apps/space /admin 대시보드 “재생 상태” 카드(PlaybackStatusCard client island)가 60초 폴링(refetchInterval:60s·refetchIntervalInBackground:false — 운영자가 화면을 볼 때만 의미 있는 지표)으로 소비 — “지금 재생 중 N/전체 · 무음 M · offline K”, 무음·offline>0 이면 warn 강조(카드를 버튼화 → PlaybackStatusDialog 드릴다운: 카드 query items 를 필터해 무음·offline 매장명·상태·마지막 접속(KST)·현재곡·행별 [매장 상세]. 별도 fetch 없이 카드 items 를 props 로 넘긴다), 0 이면 “모두 재생 중” 비강조·비버튼. 미도달 카드 옆에 나란히 배치(방송·음악 두 축).
HqPlaybackSummaryResponse / HqPlaybackSummarySeriesItem / HqPlaybackSummaryStoreItem / HqPlaybackSummaryTotalDto (#175 · #172 FU-2 — GET /api/v1/hq/playback/summary)
// generated `packages/api-client/src/generated/schemas/hqPlaybackSummaryResponse.ts`
interface HqPlaybackSummaryResponse {
bucket: "DAY" | "WEEK" | "MONTH"; // series 추이 버킷 단위(total·stores 는 버킷 무관 기간 전체 합)
from: string; // 조회 시작일(KST, 포함, YYYY-MM-DD)
to: string; // 조회 종료일(KST, 포함)
total: HqPlaybackSummaryTotalDto; // 기간 전사 합
series: HqPlaybackSummarySeriesItem[]; // 전사 버킷별 추이(bucketStart asc, 차트용)
stores: HqPlaybackSummaryStoreItem[]; // 매장별 기간 합(playedMsTotal desc, 테이블용·폐점 포함)
}
// hqPlaybackSummaryTotalDto.ts — 신탁/비신탁 분리
interface HqPlaybackSummaryTotalDto {
playedMsTotal: number; // 총 재생시간(ms) — 전 매장·전 기간
playedMsTrust: number; // 신탁 곡 재생시간(ms)
trackCount: number; // 총 재생 곡수
trackCountTrust: number; // 신탁 곡 재생 곡수
}
// hqPlaybackSummarySeriesItem.ts — 차트용(trackCountTrust 없음)
interface HqPlaybackSummarySeriesItem {
bucketStart: string; // 버킷 시작 일자(KST). DAY=당일·WEEK=주 시작(월)·MONTH=월 초
playedMsTotal: number;
playedMsTrust: number;
trackCount: number;
}
// hqPlaybackSummaryStoreItem.ts — 테이블용
interface HqPlaybackSummaryStoreItem {
storeId: string; // 매장 상세 라우팅용(/admin/stores/{storeId})
storeName: string; // 매장명(폐점 매장도 표기)
playedMsTotal: number;
playedMsTrust: number;
trackCount: number;
trackCountTrust: number;
}operationId getHqPlaybackSummary(#175, #172 FU-2). 롤업 playback_daily_rollup(매장×일 KST) 위의 조회 endpoint(데이터 모델 변경 없음) — 본사가 산하 매장 재생을 일/주/월 로 조망한다. series 는 date_trunc 로 GROUP BY 해 전사 합산(DAY=day_kst 그대로·WEEK=월요일 시작·MONTH=월 초), total·stores 는 버킷과 무관한 기간 전체 합이다. 폐점 매장 포함(과거 재생분 보존, storeName 은 현재 store 조인 — soft-deleted 매장명도 표기). 검증: from<=to·범위 상한(최대 1년) 초과 400·bucket enum 오값 400. 준수율(영업시간 대비)은 이번 제외(FU-1 영업시간 저장 선행 후 이 응답에 열 추가). hqId 토큰 주체 도출(타 본사 0). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqPlaybackSummary.
apps/space /admin/playback 재생 리포트(PlaybackReportClient)가 소비 — 기간 컨트롤(bucket 일/주/월 토글 + from·to date, 기본 DAY·최근 30일·상한 366일 클라 사전검증) + 전사 KPI(총 재생시간 h:m·총 곡수·신탁 비율) + 커스텀 SVG 추이 차트(PlaybackTrendChart — 신탁/일반 스택 막대, 운영사 signup-trend 패턴 미러·차트 라이브러리 없음) + 매장별 테이블(재생시간 desc·신탁 분리·매장 상세 링크·ErrorState 재시도·빈 상태). 리포트(실시간 아님)라 refetchIntervalInBackground:false.
PlaybackLogItemDto / PlaybackLogListResponse (#176 · #172 FU-3 — 재생 로그 뷰어)
// generated `packages/api-client/src/generated/schemas/playbackLogItemDto.ts`
interface PlaybackLogItemDto {
playedAt: string; // 재생 시작 시각(started_at, ISO-8601)
hqId: string; // 본사 id
hqName: string; // 본사명
storeId: string; // 매장 id
storeName: string; // 매장명(폐점 매장도 표기)
musicId: string; // 곡 id
musicTitle: string; // 곡명(삭제된 곡도 표기)
musicSource: "AI" | "TRUST"; // 음원 타입(업로드 시 지정·불변)
isTrust: boolean; // 신탁 곡 여부(= musicSource == TRUST)
playedMs: number; // 재생 시간(밀리초)
}
// generated `packages/api-client/src/generated/schemas/playbackLogListResponse.ts`
interface PlaybackLogListResponse {
items: PlaybackLogItemDto[]; // 재생 로그 행 목록(started_at 내림차순)
page: number; // 현재 페이지 번호(0-base)
size: number; // 페이지 크기
total: number; // 필터 매칭 전체 행 수
}운영사 listAdminPlaybackLogs(플랫폼 전체·hqId/storeId 로 좁힘) 와 본사 listHqPlaybackLogs(hqId 격리·자기 산하만) 가 동일 응답 shape 을 쓴다(운영사 본사 컬럼도 노출·본사 화면은 본사명 컬럼 생략). CSV 내보내기(exportAdminPlaybackLogs·exportHqPlaybackLogs)는 같은 필터의 전체 행(상한 50,000)을 text/csv 로 — 별도 DTO 없이 서버 스트림(FE 는 downloadCsvFromBackend BFF 경유 blob). play_log(#172 V55 적재·곡당 1행) 위의 조회로 데이터 모델 변경 없음. 정렬 started_at desc → id desc 고정. 신탁(TRUST) 음원 KOMCA 신고 근거(공식 신고 포맷은 외부 규격 게이트 — 이번은 raw 조회·CSV). apps/admin /settings/trust-playback-logs(PRD 18-5) · apps/space /admin/playback/logs 뷰어가 소비. musicSource enum 은 PlaybackLogItemDtoMusicSource(AI/TRUST).
HqSupportUnreadSignalResponse (#119 F4 — GET /api/v1/hq/support/unread-signal)
// generated `packages/api-client/src/generated/schemas/hqSupportUnreadSignalResponse.ts`
{
latestOperatorReplyAt?: string | null; // 본인 본사 ticket 의 운영자 REPLY max createdAt(없으면 null)
openOrInProgressCount: number; // 미해결(OPEN+IN_PROGRESS) ticket 수(보조)
}operationId getHqSupportUnreadSignal(D2 dot). latestOperatorReplyAt 은 ticket.updatedAt 이 아닌 댓글 createdAt 별도 조회(댓글이 updatedAt 미갱신 — 주의 2). hqId 격리. FE 는 이 값을 localStorage lastSeen(lm.support.lastSeen.hq.<hqId>)과 비교해 dot 판정(D1 — 신규 테이블 0). generated 훅 useGetHqSupportUnreadSignal. apps/space HQSidebar /admin/support 항목 dot(60초 폴링).
HqStoreListResponse / HqStoreListItem (#051 — GET /api/v1/hq/stores)
// generated `packages/api-client/src/generated/schemas/hqStoreListResponse.ts`·`hqStoreListItem.ts`
HqStoreListResponse {
items: HqStoreListItem[];
page: number; // 0-base
size: number; // 1..100
total: number; // 필터 적용 후 전체 건수
}
HqStoreListItem {
id: string;
name: string;
type: "DIRECT" | "FRANCHISE" | "INDEPENDENT"; // HqStoreListItemType
status: "ACTIVE" | "SUSPENDED" | "INACTIVE"; // HqStoreListItemStatus
address?: string | null; // @nullable
managerName?: string | null; // @nullable — 매장 담당자 이름
lastOnlineAt?: string | null; // @nullable — ISO-8601
closedAt?: string | null; // @nullable — 폐점 시각 (FE 폐점 배지)
createdAt: string; // ISO-8601
hasManagerAccount: boolean; // STORE_MANAGER(점장) 계정 존재 여부
}operationId listHqStores. query q?(매장명·주소 부분일치, 대소문자 무시, ≤100)·status?(ListHqStoresStatus)·type?(ListHqStoresType)·page·size. 정렬 status 우선순위(SUSPENDED·INACTIVE 우선)→name asc→id asc. claim↔DB 불일치·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqStores. apps/space /admin/stores 가 운영사 /stores(#044) 패턴(공용 ListToolbar/ListPagination)으로 소비 — read-only(점장 발급/관리·본사명·plan 필터 없음).
운영사 어드민
StoreAdminListItem(#044)과 비대칭 — HQ 산하 목록은 단일 본사라hqId·hqName·plan·billingAnchorDay가 없고 본사 스코프 매장 행만 노출한다.
HqStoreDetailResponse / HqStoreManagerInfo / HqStoreActivePlaylistInfo (#084·#103·#116 — GET /api/v1/hq/stores/{id})
// generated `packages/api-client/src/generated/schemas/hqStoreDetailResponse.ts`·
// `hqStoreManagerInfo.ts`·`hqStoreActivePlaylistInfo.ts`
HqStoreDetailResponse {
id: string;
name: string;
address?: string | null; // @nullable
managerName?: string | null; // @nullable — store.manager_name 연락 컬럼 (#116). 점장 계정과 별개
managerEmail?: string | null; // @nullable — store.manager_email 연락 컬럼 (#116)
phone?: string | null; // @nullable — store.manager_phone 컬럼
type: "DIRECT" | "FRANCHISE" | "INDEPENDENT"; // HqStoreDetailResponseType
status: "ACTIVE" | "SUSPENDED" | "INACTIVE"; // HqStoreDetailResponseStatus
closedAt?: string | null; // @nullable — 폐점 시각 (FE 폐점 배지)
storeManager?: HqStoreManagerInfo; // null = 점장 계정 미발급
activePlaylist?: HqStoreActivePlaylistInfo; // null = 활성 PL 없음(본사 기본 PL fallback 가능)
commercialCycleSongs?: number | null; // SPEC #103 — 매장 CM 사이클 빈도 override (null=HQ default 사용, 1..100=매장 override)
region?: Region | null; // SPEC #144 — 매장 지역(시/도) enum 17종 (null=미지정). REGION 모드 송출 그룹핑 축. HqStoreDetailResponseRegion
duckEnabled?: boolean | null; // 더킹 사용 여부 override (null=없음, HQ default 사용)
duckVolumePercent?: number | null; // 더킹 목표 볼륨(%) override (null=없음, 0..100=매장 override)
duckFadeMs?: number | null; // 더킹 fade 시간(ms) override (null=없음, 0..5000=매장 override)
hqDuckEnabled: boolean; // 본사 더킹 사용 여부 default (참조용 — override UI 상속 표기)
hqDuckVolumePercent: number; // 본사 더킹 목표 볼륨(%) default (참조용, 0..100)
hqDuckFadeMs: number; // 본사 더킹 fade 시간(ms) default (참조용, 0..5000)
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}
// 점장(STORE_MANAGER) 계정 정보 (미발급 시 null). 가장 먼저 생성된 1건.
HqStoreManagerInfo = {
email: string;
name?: string | null; // @nullable
status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // HqStoreManagerInfoStatus
} | null
// 매장 활성 플레이리스트 (없음/soft-delete 시 null — 본사 기본 PL fallback 상태는 별도 표현).
HqStoreActivePlaylistInfo = {
id: string;
name: string;
} | nulloperationId getHqStore. 매장.hqId ≠ 주체 hqId 또는 미존재 → 404 STORE_NOT_FOUND(타 본사 매장 존재 은닉). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqStore·getGetHqStoreQueryKey. apps/space /admin/stores/[id] 가 5 섹션(개요/점장 정보/활성 PL/운영 상태/메타) + CM 사이클 빈도 섹션(#103, 매장 override 편집) 로 소비. managerName·managerEmail(#116)은 개요 카드에 담당자 행으로 노출되고, 매장 정보 편집 폼(#105)이 5필드 현재값을 prefill 하는 read 소스다(점장 계정 storeManager 와 별개의 store 연락 컬럼).
updateStoreCommercialCycle / UpdateStoreCommercialCycleRequest (#103 — PATCH /api/v1/hq/stores/{id}/commercial-cycle)
// generated `packages/api-client/src/generated/schemas/updateStoreCommercialCycleRequest.ts`
UpdateStoreCommercialCycleRequest {
commercialCycleSongs?: number | null; // null = override 제거(HQ default 로 복귀), 1..100 = 매장 override 적용
}operationId updateStoreCommercialCycle. HQ_MANAGER-only. 본사 매니저가 산하 매장의 CM 사이클 빈도를 본사 default 와 다르게 설정한다(#094 F2 마감). 응답 204 — FE 가 매장 상세 invalidate 후 refetch. 본사 격리: WHERE store.hq_id = :hqId AND store.id = :id 강제 — 타 본사 매장은 404 STORE_NOT_FOUND 은닉(#084 패턴). validation: @field:Min(1) @field:Max(100) + entity init 가드. generated 훅 useUpdateStoreCommercialCycle. apps/space /admin/stores/[id] CM 사이클 섹션이 라디오(default/override) + number input + [저장] 으로 소비.
updateStoreDucking / UpdateStoreDuckingRequest (더킹 override — PATCH /api/v1/hq/stores/{id}/ducking)
// generated `packages/api-client/src/generated/schemas/updateStoreDuckingRequest.ts`
UpdateStoreDuckingRequest {
duckEnabled?: boolean | null; // @nullable — null=override 제거(HQ default 복귀), non-null=override 적용
duckVolumePercent?: number | null; // @nullable @minimum 0 @maximum 100 — null=override 제거
duckFadeMs?: number | null; // @nullable @minimum 0 @maximum 5000 — null=override 제거
}operationId updateStoreDucking. HQ_MANAGER-only. 본사 매니저가 산하 매장의 더킹을 본사 default 와 다르게
설정한다(CM 사이클 override 미러). 응답 204 — FE 가 매장 상세 invalidate 후 refetch(effective 가 점장
player me 로 read-back). ⚠️ per-field null=override 제거 의미의 전체 replace — 부분 PATCH 가 아니라
세 필드를 항상 함께 전송한다. UI 가 “본사 기본값 사용(상속)” 모드면 세 필드 모두 null(override 전체
제거 = HQ default 상속), “이 매장만 다르게(override)” 모드면 세 필드 모두 입력값. 현재 override / HQ
default 참조값은 HqStoreDetailResponse 의 duck* / hqDuck* 에서. 본사 격리:
WHERE store.hq_id = :hqId AND store.id = :id 강제 — 타 본사 매장은 404 STORE_NOT_FOUND 은닉. 검증:
duckVolumePercent 0100, 5000. generated 훅 duckFadeMs 0useUpdateStoreDucking. apps/space
/admin/stores/[id] 더킹 override 섹션이 라디오(상속/override) + 사용 토글 + 볼륨% + fade ms + [저장]
으로 소비.
updateStoreRegion / UpdateStoreRegionRequest (#144 — PATCH /api/v1/hq/stores/{id}/region)
// generated `packages/api-client/src/generated/schemas/updateStoreRegionRequest.ts`
UpdateStoreRegionRequest {
region?: Region | null; // @nullable — Region 시/도 enum 17종. 키+value=set / 키+null=clear(미지정). UpdateStoreRegionRequestRegion
}operationId updateStoreRegion. HQ_MANAGER-only. 본사 매니저가 산하 매장에 지역(시/도) 을 태그한다
(REGION 모드 송출의 그룹핑 축, #144). null=미지정(clear) — 미지정 매장은 지역 송출에 포함되지
않는다. 응답 204 — FE 가 매장 상세 invalidate 후 refetch(HqStoreDetailResponse.region 으로
read-back). 본사 격리: WHERE store.hq_id = :hqId AND store.id = :id 강제 — 타 본사 매장은 404
STORE_NOT_FOUND 은닉. 같은 트랜잭션에 audit HqAuditAction.HQ_STORE_REGION_UPDATED +
HqAuditTargetType.STORE(before/after region 스냅샷) 1건. generated 훅 useUpdateStoreRegion.
apps/space /admin/stores/[id] 매장 지역 섹션이 시/도 select(+ “미지정” 옵션) + [저장] 으로 소비
(hq-store-detail-client.tsx RegionSection). FE 한글 라벨 맵은 apps/space/src/lib/region.ts 단일 소스.
UpdateHqStoreRequest (#105 — PATCH /api/v1/hq/stores/{id})
// generated `packages/api-client/src/generated/schemas/updateHqStoreRequest.ts`
// 모든 필드는 BE 에서 `JsonNullable<String>` 으로 받는다 — PATCH 의미 D2:
// · 키 부재 = 미변경
// · 키 + null = clear (단 name 은 비-nullable 컬럼이라 null 시 400)
// · 키 + value = set (검증 통과 시)
// orval 이 JsonNullable 의 partial 의미를 OpenAPI 로 표현하지 못해 generated 타입은 5 필드 모두
// required 로 떨어진다. FE 는 변경된 필드만 담은 `Partial<>` payload 를 generated client 에 cast
// 해 보낸다(runtime 으로만 D2 의미 보존, FE 주석 `backend.ts BackendUpdateHqStoreRequest` 참조).
UpdateHqStoreRequest {
name: string; // 1..50 비-blank · null clear 불가(@nullable false)
address: string | null; // ≤200 · null=clear
managerName: string | null; // ≤50 · null=clear
managerEmail: string | null; // @Email + ≤255 · null=clear
managerPhone: string | null; // ≤30 free-form · null=clear
}operationId updateHqStore. HQ_MANAGER-only. 응답 200 HqStoreDetailResponse(read-back, D7) — FE 가 detail invalidate 부담을 덜기 위해 갱신된 detail 을 그대로 반환한다. 검증 위반 시 400 HQ_STORE_INVALID_FIELD(jakarta validation 표준이 JsonNullable 안의 값을 unwrap 하지 않아 service 가 명시 검증해 단일 도메인 exception 으로 수렴). 타 본사·미존재 매장 → 404 STORE_NOT_FOUND 존재 은닉(D6). 변경 0 = no-op 200(audit row 도 생성 X — 의미 없는 row 차단). 변경된 필드만 hq_audit_log 1행 기록: HqAuditAction.HQ_STORE_UPDATED + HqAuditTargetType.STORE, detail.changedFields 키 배열 + before/after partial 스냅샷(개인정보 최소 노출). generated 훅 useUpdateHqStore. apps/space /admin/stores/[id] 매장 정보 섹션 헤더 [편집] 토글 → 인라인 폼(5 input + [취소]/[저장]) 으로 소비(hq-store-edit-form.tsx).
운영사 어드민
StoreDetailResponse(#036)과 비대칭 — HQ 매장 상세는 본인 본사 스코프라hqId·hqName·plan·billingAnchorDay·managers[](다중) 가 없고, 대신 첫 점장 1건만storeManager로 노출한다(다중 발급 케이스는 운영사 어드민에서 관리).
CreateHqStoreRequest (#106 — POST /api/v1/hq/stores 본사 산하 매장 신규 등록, #084 F2 마감)
// generated `schemas/createHqStoreRequest.ts`. 단순 nullable 5 필드 — #105 `UpdateHqStoreRequest` 와 달리
// JsonNullable 가 아니다(신규 등록이라 `clear vs unchanged` 분기 불필요, null/생략 = 미입력 동일 의미).
// `type` 필드 없음 — 본사 유형 자동 결정(D2: INDEPENDENT 가상 본사 산하 → INDEPENDENT, 그 외 → FRANCHISE).
CreateHqStoreRequest {
name: string; // 1..50 비-blank · @NotBlank 필수
address?: string | null; // ≤200 · 선택
managerName?: string | null; // ≤50 · 선택
managerEmail?: string | null; // @Email + ≤255 · 선택
managerPhone?: string | null; // ≤30 free-form · 선택
}operationId createHqStore. HQ_MANAGER-only. 응답 201 CreateHqStoreResponse{store, managerAccount?} envelope — SPEC #184 로 flat HqStoreDetailResponse 에서 바뀐 breaking change(발급 결과를 조회·편집 공용 상세 DTO 에 섞지 않는다). FE 는 data.store.id 로 새 매장 상세(/admin/stores/{id})에 push 한다 — 예전 data.id 접근을 그대로 두면 /admin/stores/undefined 로 깨진다. 검증 위반 시 400 HQ_STORE_INVALID_FIELD(jakarta validation @Valid + service 명시 가드). 정지(SUSPENDED) 본사는 등록 거부 → 403 AUTH_HQ_SUSPENDED (login 차단이 first line, service 가드 second line — D5). 동일 본사 내 name 중복 허용(분점 패턴, D6). SPEC #184 (D1·D4) — 점장 계정 발급이 통합됐다: managerEmail 이 채워지면 같은 트랜잭션에서 STORE_MANAGER 계정이 생성되고 계정 설정 메일이 커밋 후 발송된다(임시 비밀번호는 서버 생성 · 응답 미노출 — D6). 미입력이면 “점장 미정”(D2) → POST /api/v1/hq/stores/{storeId}/managers 로 사후 발급. 이메일 중복 → 409 DUPLICATE_EMAIL + 매장 롤백(D9, 고정 문구 — 계정 열거 차단). rate limit STORE_PROVISION 20회/분 → 429 RATE_LIMITED. audit hq_audit_log 기록: HqAuditAction.HQ_STORE_CREATED(발급 시 HQ_STORE_MANAGER_ISSUED 별도 row 추가) + HqAuditTargetType.STORE 재사용, detail = {name, type, address, managerName} partial 스냅샷(편집 액션과 의미 분리, D7). impersonation 컨텍스트는 actorRole=OPERATOR_IMPERSONATING + impersonatedByEmail 동시 기록. generated 훅 useCreateHqStore. apps/space /admin/stores/new 단일 단계 폼(5 input + [취소]/[등록]) 으로 소비(hq-store-create-form.tsx) — 매장 목록 헤더 [+ 매장 등록] 진입.
BulkCreateHqStoreResponse / BulkRowResult / BulkRowStatus (#111 — POST /api/v1/hq/stores/bulk CSV 일괄 등록, #084 F3 마감)
// generated `schemas/bulkCreateHqStoreResponse.ts`·`bulkRowResult.ts`·`bulkRowResultStatus.ts`.
// 요청은 DTO 가 아니라 multipart `file`(CSV) — generated `BulkCreateHqStoresBody{ file: Blob }`.
// CSV 헤더 5컬럼 정확 일치: 매장명,주소,담당자명,담당자이메일,담당자전화 (UTF-8, BOM 제거, RFC4180).
BulkCreateHqStoreResponse {
totalRows: number; // 처리한 전체 데이터 행 수(헤더 제외)
successCount: number; // 등록 성공 행 수
failureCount: number; // 등록 실패 행 수
// SPEC #184 — 점장 계정 발급 결과 합계.
managerAccountCreatedCount: number; // 계정까지 함께 발급된 행 수. successCount 와의 차이 = "점장 미정" 매장 수
setupEmailFailedCount: number; // 계정은 생겼지만 setup 메일 발송에 실패한 행 수(재발송 필요)
results: BulkRowResult[]; // 행별 처리 결과(입력 순서, rowNumber asc)
}
BulkRowResult {
rowNumber: number; // 헤더 제외 1-base 데이터 행 번호
status: "SUCCESS" | "FAILED"; // BulkRowResultStatus — SUCCESS(등록됨)/FAILED(거부됨, 다른 행 무영향)
storeId?: string | null; // 등록된 매장 id(성공 시에만)
storeName?: string | null; // 성공=등록된 이름 / 실패=입력값(비면 null)
errorCode?: string | null; // 실패 사유 코드(주로 HQ_STORE_INVALID_FIELD · 이메일 중복은 DUPLICATE_EMAIL)
errorMessage?: string | null; // 실패 사유 안내 문구(사용자 안전 문구)
// SPEC #184 — 행별 점장 계정 발급 결과.
managerAccountCreated: boolean; // 의도적 파생 필드(= setupEmailSent != null). 실패 행은 항상 false
setupEmailSent?: boolean | null; // 계정 미발급 행은 null · 발송 성공 true · **발송 실패 false**
}operationId bulkCreateHqStores. HQ_MANAGER-only. consumes multipart/form-data(@RequestPart("file")). #106 단일 등록을 행 단위로 미러한다. SPEC #184 (D6) — “담당자이메일” 컬럼이 점장 로그인 ID 로 승격됐다: 채워진 행은 매장 + 점장 계정이 함께 생성되고 임시 비밀번호는 서버 생성 + 응답 미노출(setup 메일 전용 진입). setup 메일은 행 트랜잭션 밖에서 루프 종료 후 일괄 발송한다(행마다 외부 왕복이면 1000행 상한에서 BFF 타임아웃 + 커넥션 점유). rate limit 은 별도 그룹 BULK_PROVISION 계정당 3회/분 → 초과 429 RATE_LIMITED. 이메일 중복은 그 행만 errorCode=DUPLICATE_EMAIL 로 실패한다. 부분 실패 = 행별 독립 처리 + 성공분 커밋 + 결과 리포트(D2 — 한 행 실패가 전체 롤백 X). 행 검증은 #106 미러(HQ_STORE_INVALID_FIELD). type 자동 결정(D5)·중복 매장명 새 생성(D3)·audit 행별 HQ_STORE_CREATED(D6). 파일 자체 오류(헤더 불일치·빈 파일·파싱 불가)는 행 처리 전 400 HQ_STORE_BULK_INVALID_FILE(행 검증 실패와 구분 — 행 실패는 200 결과 리포트에 담긴다). 정지(SUSPENDED) 본사는 파일 처리 전 가드 403 AUTH_HQ_SUSPENDED(D5). generated 훅 useBulkCreateHqStores(multipart mutator — 음원 업로드 #041 선례 미러, FormData file 파트). apps/space /admin/stores/bulk 파일 업로드 + 결과 리포트(요약 + 실패 행 테이블) 로 소비(bulk-client.tsx) — 매장 목록 헤더 [CSV 일괄 등록] 진입. atom-grounded 임시(시안 부재 — design-debt §2).
CreateHqStoreResponse / HqStoreManagerIssueResult (#184 — POST /api/v1/hq/stores 응답 envelope)
// generated `schemas/createHqStoreResponse.ts`·`hqStoreManagerIssueResult.ts`
// ⚠️ SPEC #184 breaking — 이전에는 201 body 가 flat `HqStoreDetailResponse` 였다.
CreateHqStoreResponse {
store: HqStoreDetailResponse; // 생성된 매장 상세(read-back)
managerAccount?: HqStoreManagerIssueResult; // 계정을 함께 발급했을 때만. "점장 미정"이면 null
}
HqStoreManagerIssueResult {
managerAccountId: string; // 발급된 점장 계정 id
email: string; // 발급된 로그인 ID(정규화된 이메일)
setupEmailSent?: boolean | null; // 발송 성공 true · **발송 실패 false**(계정 진입 경로 없음 → 재발송 필요)
}발급 결과를 조회·편집 공용 상세 DTO(HqStoreDetailResponse)에 섞지 않기 위한 envelope 다.
FE 는 data.store.id 로 새 매장 상세에 진입하고, data.managerAccount?.setupEmailSent === false
면 상세로 넘어가기 전에 “계정 설정 안내 메일 미발송” 을 danger 로 알린다(본사는 재발송 권한이
없으므로 — 재발송은 OPERATOR-only — 운영사 문의로 잇는다).
IssueHqStoreManagerRequest / HqStoreManagerIssueResponse (#184 — POST /api/v1/hq/stores/{storeId}/managers)
// generated `schemas/issueHqStoreManagerRequest.ts`·`hqStoreManagerIssueResponse.ts`
IssueHqStoreManagerRequest {
name?: string | null; // 점장 이름 · ≤50 · 선택
email: string; // 점장 로그인 ID · @Email + ≤255 · **필수**
// ⚠️ tempPassword 필드가 **없다** — 임시 비밀번호는 서버 생성이고 응답에도 노출하지 않는다(D6).
// 계정 설정(setup) 메일이 유일한 진입 경로다.
}
HqStoreManagerIssueResponse {
storeId: string;
managerAccount: HqStoreManagerIssueResult;
}operationId issueHqStoreManager. HQ_MANAGER-only(SPEC #184 D4 신규 권한) — 본사가 자기 산하
매장에 점장 계정을 사후 발급한다. 생성 시점을 놓친 매장의 유일한 복구 경로다: “점장 미정”
등록(D2) · 이메일 중복으로 계정 없이 재등록(D9) · 점장 회수(WITHDRAWN) 후 재발급(D8 / 감사 AC-F1).
대상은 GET /api/v1/hq/stores?hasManagerAccount=false 로 색출한다. 스코프는 PrincipalScopeGuard
로 재검증하고 타 본사·미존재 매장은 403 PRINCIPAL_SCOPE_MISMATCH 로 은닉(존재 유무 미노출).
정지 본사 403 AUTH_HQ_SUSPENDED · 이메일 중복 409 DUPLICATE_EMAIL(고정 문구 — 임의 이메일의
409/201 관측으로 플랫폼 계정을 열거하지 못하게 입력 이메일을 담지 않는다) · rate limit
STORE_PROVISION 20회/분 초과 429 RATE_LIMITED. 매장당 복수 점장 계정 허용. 발급마다 audit
HqAuditAction.HQ_STORE_MANAGER_ISSUED(총 18종) + HqAuditTargetType.STORE. generated 훅
useIssueHqStoreManager. apps/space /admin/stores/[id] 점장 정보 섹션 [점장 발급] 다이얼로그로
소비(hq-store-manager-issue-dialog.tsx — 미발급·회수 상태에서만 노출, 폐점 매장 제외).
전용 시안 부재 → atom-grounded(#106 폼 idiom + 본사 다이얼로그 골격).
HQ Mode 플레이리스트 조회 DTOs (#057 — apps/space /admin/playlists read-only)
본사(HQ_MANAGER)가 자기 본사 플레이리스트와 적용 현황을 조회만 한다(편집은 운영사 apps/admin 전용). hqId 는 토큰 주체 도출(요청 파라미터 없음, verifyHqScope — 타 본사 PL 비노출).
HqPlaylistListResponse / HqPlaylistListItem (#057 — GET /api/v1/hq/playlists)
// generated `schemas/hqPlaylistListResponse.ts`·`hqPlaylistListItem.ts`·`listHqPlaylistsParams.ts`
ListHqPlaylistsParams {
q?: string; // 이름 부분일치(대소문자 무시), 0..100
page?: number; // 0-base
size?: number; // 1..100 clamp
}
HqPlaylistListResponse {
items: HqPlaylistListItem[];
page: number; // 0-base
size: number; // 1..100
total: number; // 필터 적용 후 전체 건수
}
HqPlaylistListItem {
id: string;
name: string;
isDefault: boolean; // (#058) 본사 기본 PL 여부 — read-only 배지(지정은 운영사 전용)
libraryCount: number; // 담긴 활성 라이브러리 수
appliedStoreCount: number; // 이 PL 을 활성으로 쓰는 본인 본사 매장 수
status: PlaylistStatus; // (#060) 파생 상태 EMPTY|UNUSED|ACTIVE|FALLBACK — read-only 상태 배지
updatedAt: string; // ISO-8601
}operationId listHqPlaylists. 정렬 created_at DESC, id DESC 서버 고정. claim↔DB 불일치·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqPlaylists. apps/space /admin/playlists 가 본사 /stores(#051) 패턴(공용 ListToolbar/ListPagination)으로 소비 — read-only(생성/편집/삭제·적용·기본 지정 진입점 없음). 각 행에 읽기 전용 상태 배지(#060 — 사용 중/기본/미사용/비어있음)를 노출한다. status=FALLBACK 이면 상태 배지가 “기본” 을 표현하므로 별도 isDefault “기본” 배지는 숨겨 중복을 피하고, 그 외 status 인 기본 PL(예: 라이브러리 0 → EMPTY)은 isDefault “기본” 배지를 함께 노출한다(#058). 각 행은 상세(/admin/playlists/[id])로 링크.
HqPlaylistDetailResponse / HqPlaylistLibraryItem (#057 — GET /api/v1/hq/playlists/{id})
// generated `schemas/hqPlaylistDetailResponse.ts`·`hqPlaylistLibraryItem.ts`
HqPlaylistDetailResponse {
id: string;
name: string;
isDefault: boolean; // (#058) 본사 기본 PL 여부 — read-only 배지(헤더)
libraryCount: number; // 담긴 활성 라이브러리 수
appliedStoreCount: number; // 이 PL 을 활성으로 쓰는 본인 본사 매장 수
status: PlaylistStatus; // (#060) 파생 상태 EMPTY|UNUSED|ACTIVE|FALLBACK — read-only 상태 배지(헤더)
libraries: HqPlaylistLibraryItem[]; // position 순(server 정렬)
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}
HqPlaylistLibraryItem {
libraryId: string;
name: string;
libraryType: string; // "AI" · "TRUST" (그 외 타입은 원문 노출)
musicCount: number; // 담긴 활성 음원 수
position: number; // 0-base
}operationId getHqPlaylist. PL.hqId ≠ 주체 hqId 또는 미존재 → 404 PLAYLIST_NOT_FOUND(타 본사 PL 존재 은닉). generated 훅 useGetHqPlaylist. apps/space /admin/playlists/[id] 가 server-side(refresh-aware)로 fetch — 404 → notFound(), 5xx/네트워크 → 재시도 배너. read-only(추가/제거/순서/이름수정/삭제 진입점 없음).
운영사 어드민 PL DTO(#054)와 비대칭 — HQ 상세는 단일 본사라 소속 본사 그룹·커버리지·편집 액션이 없고, 대신 적용 매장 수(자기 본사 매장 중 활성 사용)를 집계해 노출한다.
HQ Mode 라이브러리 조회 DTOs (#080 — apps/space /admin/libraries read-only)
본사(HQ_MANAGER)가 자기 본사 PL 에 담을 후보 라이브러리를 조회만 한다(편집·음원 할당은 운영사 apps/admin 전용). 가시 범위는 운영사가 만든 모든 라이브러리(본사 PL #057 과 동일 패턴 — plan/type mismatch 게이트는 후속 #060 F1).
HqLibraryListResponse / HqLibraryListItem (#080 — GET /api/v1/hq/libraries)
// generated `schemas/hqLibraryListResponse.ts`·`hqLibraryListItem.ts`·`listHqLibrariesParams.ts`
ListHqLibrariesParams {
q?: string; // 이름 부분일치(대소문자 무시), 0..100
type?: "AI" | "TRUST"; // 라이브러리 타입 필터(생략=전체)
page?: number; // 0-base
size?: number; // 1..100 clamp
}
HqLibraryListResponse {
items: HqLibraryListItem[];
page: number; // 0-base
size: number; // 1..100
total: number; // 필터 적용 후 전체 건수
}
HqLibraryListItem {
id: string;
name: string;
type: "AI" | "TRUST"; // 라이브러리는 단일 타입 묶음(음원 enforcement #059)
trackCount: number; // 소속 활성 음원 수
playlistCount: number; // 이 라이브러리를 사용 중인 본인 본사 PL 수(타 본사 PL 제외)
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}operationId listHqLibraries. 정렬 name ASC 서버 고정. claim↔DB 불일치·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqLibraries. apps/space /admin/libraries 가 본사 /admin/playlists(#057) 패턴(공용 ListToolbar/ListPagination)으로 소비 — read-only(생성/편집/삭제·음원 할당 진입점 없음). 행 컬럼: 이름·타입 배지(AI=info/TRUST=success)·음원 수·PL 사용 수·수정 시각(KST). 상세 view 는 후속 F1.
운영사 라이브러리 DTO(#053)와 비대칭 — HQ 목록은 본인 본사 PL 사용 수(
playlistCount)만 집계(타 본사 PL 제외, 본사가 후보 선별 시 자기 PL 활용도 확인용)·생성/편집/음원 할당 액션 없음.
HqLibraryDetailResponse / HqLibraryMusicListResponse / HqLibraryMusicListItem (#107 — GET /api/v1/hq/libraries/{id} · .../{id}/music)
// generated `schemas/hqLibraryDetailResponse.ts`·`hqLibraryMusicListResponse.ts`·
// `hqLibraryMusicListItem.ts`·`listHqLibraryMusicParams.ts`·`hqLibraryDetailResponseType.ts`
HqLibraryDetailResponse {
id: string;
name: string;
type: "AI" | "TRUST"; // HqLibraryDetailResponseType — 라이브러리는 단일 타입 묶음(상세 헤더 배지 한 곳)
trackCount: number; // 소속 활성 음원 수
playlistCount: number; // 이 라이브러리를 사용 중인 본인 본사 활성 PL 수(타 본사 PL 제외)
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}
ListHqLibraryMusicParams {
page?: number; // 0-base
size?: number; // 1..100 clamp (기본 20)
}
HqLibraryMusicListResponse {
items: HqLibraryMusicListItem[];
page: number; // 0-base
size: number; // 1..100
total: number; // 라이브러리에 담긴 활성 음원 전체 건수
}
HqLibraryMusicListItem { // 트랙(담긴 음원) 행 — 할당 시각 DESC
id: string;
title: string;
durationSeconds: number; // 곡 길이(초) — FE 에서 mm:ss(또는 h:mm:ss) 포맷
createdAt: string; // ISO-8601 (할당 시각)
updatedAt: string; // ISO-8601
}operationId getHqLibrary·listHqLibraryMusic. 라이브러리는 운영사 공유 모델(hqId 컬럼 없음)이라 verifyHqScope(403 가드)만 통과하면 모든 활성 라이브러리 조회 가능 — PL 상세(#057)의 hqId != → 404 은닉 분기는 없다(#080 §D3). 미존재/soft-deleted → 404 LIBRARY_NOT_FOUND. claim↔DB 불일치·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqLibrary·useListHqLibraryMusic. apps/space /admin/libraries/[id] 가 메타는 server fetch(404→notFound()), 트랙은 client query(useListHqLibraryMusic, 페이지네이션)로 소비 — read-only(음원 추가/제거·이름 수정·삭제·체크박스 없음). 트랙 행 컬럼: 제목·길이(mm:ss)·추가일(KST). audioUrl·트랙별 타입 배지 미포함(라이브러리 단일 타입).
운영사 라이브러리 상세(#053
getLibrary+listLibraryMusic)와 idiom 일관, 단 본사 view 는 read-only — 운영사의 음원 담기/제거·이름 수정·삭제 mutation 진입점이 없다.
HQ Mode TTS 안내방송 DTOs (#061 — apps/space /admin/announcements)
본사(HQ_MANAGER)가 텍스트+voice+톤 프리셋으로 합성한 안내방송. 생성=합성(동기 완료)·목록·상세·수정(조건부
재합성, #062)·삭제. voice 는 TtsVoice 5종(enums)
— generated 노출 enum CreateTtsAnnouncementRequestVoice/UpdateTtsAnnouncementRequestVoice/
TtsAnnouncementListItemVoice/TtsAnnouncementDetailResponseVoice. 톤 프리셋은 TtsTonePreset
6종(enums, #063) — voice별 허용 집합이
다르며 모든 voice 가 NORMAL(기본) 지원.
CreateTtsAnnouncementRequest (#061·#063 — POST /api/v1/hq/announcements)
// generated `schemas/createTtsAnnouncementRequest.ts`
CreateTtsAnnouncementRequest {
title: string; // @maxLength 255 — 목록/식별용 제목
text: string; // @NotBlank @maxLength 1000 — 합성할 본문
voice: CreateTtsAnnouncementRequestVoice; // TtsVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA)
tonePreset?: CreateTtsAnnouncementRequestTonePreset; // TtsTonePreset 6종, @nullable, 기본 NORMAL (#063)
tempo?: number; // (#139) @minimum 0.5 @maximum 2 @nullable — 발화 속도 배수, 미지정 시 서버 기본 1.0
}FE 검증은 OpenAPI 제약과 일치(frontend.md §8) — 빈 title/text 는 클라이언트에서 submit disable.
voice 라벨(한글)은 합성 전이라 BE 표시명을 못 받으므로 FE tts-voice-meta.ts 보유(value→라벨).
톤 프리셋(#063): voice 연동 select — listHqTtsVoices(TtsVoiceListResponse)로 voice별 허용 톤을
받아 선택 voice 의 presets 만 노출, voice 변경 시 허용 밖이면 NORMAL 로 리셋(UI 가 위반 차단).
발화 속도(tempo, #139): voice·톤과 동일한 방송별 파라미터. FE 는 0.5~2.0(step 0.1) select 로 노출(기본
1.0=1.0× (기본)), value→payload 직결. 범위 밖이면 400(서버 검증).
UpdateTtsAnnouncementRequest (#062·#063 — PUT /api/v1/hq/announcements/{id})
// generated `schemas/updateTtsAnnouncementRequest.ts`
UpdateTtsAnnouncementRequest {
title: string; // @maxLength 255
text: string; // @maxLength 1000 — 기존과 다르면 재합성된다
voice: UpdateTtsAnnouncementRequestVoice; // TtsVoice 5종(동일 enum)
tonePreset?: UpdateTtsAnnouncementRequestTonePreset; // TtsTonePreset 6종, @nullable (#063)
tempo?: number; // (#139) @minimum 0.5 @maximum 2 @nullable — 기존과 다르면 재합성된다
}전체 교체(PUT). text·voice·tonePreset·tempo 중 하나라도 기존과 다르면 BE 가 재합성(같은 blob url
덮어쓰기 + durationSeconds 갱신), title 만 바뀌면 재합성 skip(§5-1) — 판정은 BE, FE 는 동일 “합성
중…” pending. 성공 응답은 TtsAnnouncementDetailResponse(200, tempo: number 포함). FE 는 행 [수정] 시
상세를 fetch 해 초기값(title·text·voice·tonePreset·tempo)을 채운 create/edit 겸용 다이얼로그로
제출(useUpdateHqTtsAnnouncement). 재생 캐시버스터로 응답 updatedAt 을 audioUrl?v= 에 사용.
TtsVoiceListResponse / TtsVoiceOption / TtsTonePresetOption (#063 — GET /api/v1/hq/tts-voices)
// generated `schemas/ttsVoiceListResponse.ts`·`ttsVoiceOption.ts`·`ttsTonePresetOption.ts`
TtsVoiceListResponse {
voices: TtsVoiceOption[]; // 5종, voice 별 허용 톤 프리셋 매핑
}
TtsVoiceOption {
voice: TtsVoiceOptionVoice; // TtsVoice
voiceDisplayName: string; // voice 한글 표시명
presets: TtsTonePresetOption[]; // 이 voice 가 지원하는 톤(항상 NORMAL 포함)
}
TtsTonePresetOption {
preset: TtsTonePresetOptionPreset; // TtsTonePreset
presetDisplayName: string; // 톤 한글 표시명(BE 단일 소스)
}생성·수정 다이얼로그가 열릴 때 1회 조회해 톤 select 를 voice 에 연동한다(useListHqTtsVoices). voices
응답이 아직/실패면 FE 는 폴백으로 NORMAL 만 노출(tts-voice-meta.ts 정적 라벨)해 폼이 깨지지 않는다.
TtsAnnouncementDetailResponse (#061·#063·#152 — POST·PUT·GET .../{id} 성공)
// generated `schemas/ttsAnnouncementDetailResponse.ts`
TtsAnnouncementDetailResponse {
id: string;
title: string;
text: string; // 합성에 쓴 원문
voice: TtsAnnouncementDetailResponseVoice; // TtsVoice
voiceDisplayName: string; // voice 한글 표시명(BE 단일 소스)
tonePreset: TtsAnnouncementDetailResponseTonePreset; // TtsTonePreset (#063)
tonePresetDisplayName: string; // 톤 한글 표시명(BE 단일 소스, #063)
audioUrl: string; // Azure public blob — 직접 재생
durationSeconds?: number | null; // Typecast 가 줄 때만(nullable)
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
activeScheduleCount: number; // (#152) 연결된 활성(ACTIVE) 반복예약 수. 0=없음. 삭제 시 함께 취소될 건수 — FE 삭제 다이얼로그 경고용.
}생성(201)·상세(200) 공통 응답. FE 목록 행은 [재생] 시 상세를 lazy fetch 해 audioUrl 로
<audio controls autoPlay src={audioUrl}> 재생(N+1 회피).
TtsAnnouncementListResponse / TtsAnnouncementListItem (#061·#063·#066·#097·#152 — GET /api/v1/hq/announcements)
// generated `schemas/ttsAnnouncementListResponse.ts`·`ttsAnnouncementListItem.ts`
TtsAnnouncementListResponse {
items: TtsAnnouncementListItem[];
page: number; // 0-base
size: number; // 1..100 clamp
total: number; // 활성(soft-delete 제외) 전체 건수
}
TtsAnnouncementListItem {
id: string;
title: string;
voice: TtsAnnouncementListItemVoice; // TtsVoice
voiceDisplayName: string; // voice 한글 표시명
tonePreset: TtsAnnouncementListItemTonePreset; // TtsTonePreset (#063)
tonePresetDisplayName: string; // 톤 한글 표시명 (#063)
durationSeconds?: number | null; // null 가능
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
dispatchCount: number; // (#066) 누적 송출 row 수(=중복 송출 포함). 0=미송출. **고유 매장 수 아님** — 같은 매장에 N 번 송출하면 N. 매장 그룹핑은 후속(F).
lastDispatchedAt?: string | null; // (#066) 가장 최근 송출 시각 ISO-8601(`MAX(announcement_dispatch.created_at)`). null=미송출.
lastDispatchStoreCount?: number | null; // (#097) 마지막 송출 호출의 distinct 매장 수(fan-out 사이즈). null=미송출. **누적 아님** — 마지막 1회 호출만 카운트. audit_id 그룹핑(V28+) + legacy created_at fallback. UI 는 같은 셀에 `(N매장)` 보조 표기.
activeScheduleCount: number; // (#152) 연결된 활성(ACTIVE) 반복예약 수(CANCELED 제외). 0=없음. 삭제 시 cascade 취소될 건수 — FE 삭제 다이얼로그가 "반복예약 N건도 함께 취소됩니다" 경고에 사용.
}#066 — list 송출 요약: 본사가 행에서 “몇 번 송출됐는지·마지막이 언제인지”를 바로 확인하고 송출 이력 다이얼로그(#065) 진입 결정을 가볍게 한다. BE 는
LEFT JOIN announcement_dispatch d ON d.announcement_id = t.id AND d.hq_id = t.hq_id+GROUP BY평탄 projection 한 쿼리(D2·D3 V26 인덱스 재활용). 정렬·페이징·검색·source=‘HQ’ 격리는 기존 그대로(D4·D10). 상세 DTO 에는 추가하지 않는다(이력 endpoint 가 같은 시그널을 더 풍부하게 제공 — D12).
#097 — 마지막 송출 fan-out 사이즈: list 행 단위로 “이번 마지막 송출은 N매장 대상” 직관 제공. BE 는
LEFT JOIN LATERAL last_call (LIMIT 1)으로 마지막 dispatch row 의audit_id(V28+ 호출 단위 식별자)
created_at(legacy fallback) 을 announcement 당 1회 산출, scalar subquery 가 같은 호출 row 들의 distinctstore_id를 CAST(… AS int) 한다. 같은 ms tick 동률 호출 시 tiebreak 가 약하지만 audit_id 그룹핑 덕에 두 호출 fan-out 이 섞이지 않고 한 호출로 수렴. FE 는lastDispatchedAt셀 같은 자리에(N매장)보조 표기, 미송출/legacy null 은 표시 없음(noise 차단).
list item 에는
audioUrl이 없다(상세에만) — 목록 행 재생은 상세 endpoint 를 lazy 호출해 audioUrl 을 얻는다. operationIdlistHqTtsAnnouncements. queryq?·page·size. 정렬created_at DESC서버 고정. hqId 토큰 주체 도출(타 본사 비노출). 삭제는deleteHqTtsAnnouncement(204 soft-delete), 응답 body 없음.
DispatchRequest / DispatchResponse (송출 슬라이스 — POST /api/v1/hq/announcements/{id}/dispatch)
// generated `schemas/dispatchRequest.ts`·`dispatchResponse.ts`·`dispatchRequestTarget.ts`
DispatchRequest {
target: DispatchRequestTarget; // "ALL" | "STORES" | "REGION" (#144)
storeIds?: (string | null)[] | null; // STORES 일 때 필수, 모두 본인 본사 산하 (ALL/REGION 이면 무시). 크기 ≤1000 은 target 무관 적용(페이로드 가드)
region?: Region | null; // (SPEC #144) REGION 일 때 필수(시/도 enum). 누락 시 400 DISPATCH_REGION_REQUIRED. ALL/STORES 면 무시. DispatchRequestRegion
scheduledAt?: string | null; // (SPEC #078) ISO-8601. null=즉시 송출(기존 동작·PENDING fan-out), non-null=예약(SCHEDULED row 적재 → 디스패처 전이). 과거=400 DISPATCH_SCHEDULED_AT_PAST · 1년 초과=400 DISPATCH_SCHEDULED_AT_TOO_FAR
}
DispatchResponse {
dispatchedCount: number; // 생성된 송출 row 수 (매장 0건이면 0)
dispatchIds: string[]; // 생성된 송출 id 목록 (매장당 1개)
}operationId
dispatchHqTtsAnnouncement(201). 매장당 1개 PENDING(즉시) 또는 SCHEDULED(예약)announcement_dispatchrow 를 fan-out.target=ALL=산하 매장 전체(폐점 매장 제외·정지/비활성 포함),STORES=지정 매장 (빈 배열 → 400 검증 오류(minItems:1) / storeIds 생략(null) → 400DISPATCH_INVALID_TARGET/ 미존재·타 본사 혼입 → 404TTS_ANNOUNCEMENT_NOT_FOUND전체 은닉, 부분 송출 없음).target=REGION(#144)=region(시/도) 으로hqId AND region=:region매장에 fan-out (region 누락 → 400DISPATCH_REGION_REQUIRED· 해당 region 매장 0건 →dispatchedCount=0). 중복 송출 허용. 미존재 안내방송 → 404TTS_ANNOUNCEMENT_NOT_FOUND. 예약 송출(SPEC #078):scheduledAtnon-null 이면 row 가status=SCHEDULED, scheduled_at=scheduledAt으로 적재되고 백그라운드HqDispatchScheduler(@Scheduled(cron='0 * * * * *')) 가 도래 시 원자 UPDATE 로 PENDING 전이 → 점장 player 가 자연 픽업.DispatchRequestTargetenum(enums) ·DispatchStatusenum(enums).
CreateDispatchScheduleRequest / DispatchScheduleResponse (반복 송출 예약 — POST/GET /api/v1/hq/dispatch-schedules, #139 확장)
// generated `schemas/createDispatchScheduleRequest.ts`·`dispatchScheduleResponse.ts`·`*Frequency.ts`·`*Target.ts`
CreateDispatchScheduleRequest {
announcementId: string; // 반복 송출할 안내방송 id (본인 본사 활성 HQ 출처)
target: CreateDispatchScheduleRequestTarget; // "ALL" | "STORES" | "REGION" (#144)
storeIds?: (string | null)[] | null; // STORES 일 때 필수(1~1000, 모두 산하). ALL/REGION 이면 무시
region?: Region | null; // (#144) REGION 일 때 필수(시/도). 누락 시 400 DISPATCH_REGION_REQUIRED. ALL/STORES 면 무시. CreateDispatchScheduleRequestRegion
frequency: CreateDispatchScheduleRequestFrequency; // (#139) DAILY|WEEKLY|HOURLY|EVEN_HOURS|ODD_HOURS
byWeekday?: string | null; // WEEKLY 필수 — 요일 CSV(MON~SUN, @maxLength 32). 그 외 무시
startHour?: number | null; // (#139) 시각형 빈도 운영시간 시작 hour. @minimum 0 @maximum 23, ≤endHour. 시각형이면 필수, DAILY/WEEKLY 무시
endHour?: number | null; // (#139) 시각형 빈도 운영시간 종료 hour. @minimum 0 @maximum 23, ≥startHour. 시각형이면 필수
slotTime: string; // 'HH:MM' KST 5분 슬롯(분 5배수). @maxLength 5. 시각형은 분(MM)만 매시 :MM 오프셋(시 무시)
startsOn: string; // 전개 시작일 (KST date, 포함)
endsOn?: string | null; // 종료일(포함). null=무한. 지정 시 ≥startsOn
}
DispatchScheduleResponse { // 동일 필드 + id·timezone·status·createdAt
id: string; announcementId: string;
target: DispatchScheduleResponseTarget; storeIds: string[]; // REGION/ALL 이면 storeIds 빈 배열
region?: Region | null; // (#144) REGION 이면 시/도, ALL/STORES 면 null. DispatchScheduleResponseRegion
frequency: DispatchScheduleResponseFrequency; // (#139) 5종
byWeekday?: string | null; // WEEKLY 정규화 CSV, 그 외 null
startHour?: number | null; // (#139) 시각형이면 운영시간, DAILY/WEEKLY 면 null
endHour?: number | null; // (#139) 동일
slotTime: string; startsOn: string; endsOn?: string | null;
timezone: string; // KST 고정
status: DispatchScheduleResponseStatus; // ACTIVE → CANCELED 단방향
createdAt: string;
}빈도↔요일·빈도↔운영시간 불일치·
slotTime형식 위반·endsOn<startsOn·운영시간 범위(startHour>endHour·0-23 밖) → 400DISPATCH_SCHEDULE_INVALID. 시각형(HOURLY/EVEN_HOURS/ODD_HOURS)이면startHour·endHour필수이고slotTime의 시(HH)는 무시(분 MM만 매시 오프셋) — FE 는00:MM으로 전송. 백그라운드 디스패처가 14일 윈도우로 SCHEDULED dispatch 전개(시각형은 하루 N건).DispatchScheduleFrequencyenum(enums). 상세는 반복 송출 예약.rate limit: 반복 예약 생성(
POST /api/v1/hq/dispatch-schedules,createHqDispatchSchedule)·취소(PATCH .../{id}/cancel,cancelHqDispatchSchedule)는 본사 제어 액션으로HQ_CONTROL그룹 30/분(APP_RATE_LIMIT_HQ_CONTROL_PER_MINUTE) rate limit 대상 — 초과 시 429RATE_LIMITED+Retry-After(error-codes).
DispatchCalendarResponse / DispatchCalendarEvent (송출 캘린더 — GET /api/v1/hq|store/dispatch-calendar)
// generated `schemas/dispatchCalendarResponse.ts`·`dispatchCalendarEvent.ts`·`dispatchCalendarEventKind.ts`·`dispatchCalendarEventStatus.ts`
DispatchCalendarResponse {
from: string; // 조회 시작일(KST day, 포함) — 요청 echo
to: string; // 조회 종료일(KST day, 포함) — 요청 echo
events: DispatchCalendarEvent[]; // effective time ASC → id ASC 결정적·0건이면 빈 배열
}
DispatchCalendarEvent {
dispatchId: string;
scheduledAt: string; // 캘린더 배치 시각(UTC ISO). 예약=scheduled_at·즉시=created_at COALESCE 파생.
// FE 가 KST day 로 변환해 셀에 배치.
storeId: string;
storeName?: string | null; // 본사 캘린더만 채워짐(점장은 본인 매장 고정 → null)
announcementTitle: string;
kind: DispatchCalendarEventKind; // "EMERGENCY" | "HQ_ANNOUNCEMENT" | "STORE_BROADCAST"
status: DispatchCalendarEventStatus; // "SCHEDULED" | "PENDING" | "PLAYED" | "CANCELED"
isEmergency: boolean;
scheduleId?: string | null; // 반복 전개분이면 규칙 id, 1회성은 null
}본사·점장이 같은 DTO 를 공유한다(점장은
storeNamenull). queryfrom·to(KST day,YYYY-MM-DD)는 최대 62일 — 초과/역순 → 400DISPATCH_CALENDAR_INVALID_RANGE. FE 월 그리드는 보는 달 그리드 범위 (≤42칸)만 fetch 해 cap 안에서 단일 호출한다.kind/statusenum 은 enums. 상세는 본사 캘린더·점장 캘린더.
DispatchHistoryResponse / DispatchHistoryItem / DispatchHistoryAggregate (#065 — GET /api/v1/hq/announcements/{id}/dispatches)
// generated `schemas/dispatchHistoryResponse.ts`·`dispatchHistoryItem.ts`·`dispatchHistoryAggregate.ts`·`dispatchHistoryItemStatus.ts`·`dispatchHistoryItemMissedReason.ts`·`dispatchHistoryItemActorRole.ts`
DispatchHistoryResponse {
items: DispatchHistoryItem[]; // 현재 페이지 송출 이력 행
page: number; // 0-base
size: number; // 1..100 clamp
total: number; // 해당 announcement scope 의 전체 송출 row 수
aggregate: DispatchHistoryAggregate; // 페이지 무관 전체 카운트
}
DispatchHistoryItem {
dispatchId: string; // 송출 id (announcement_dispatch.id)
storeId: string; // 수신 매장 id
storeName: string; // 수신 매장 이름 (BE 단일 소스 — store join)
status: DispatchHistoryItemStatus; // (#077·#078·#143 5종) "SCHEDULED" | "PENDING" | "PLAYED" | "CANCELED" | "MISSED"
createdAt: string; // 송출 row 생성 시각 (ISO-8601, 예약 등록 시각 = SCHEDULED 의 등록 시각)
playedAt?: string | null; // **실제 재생 완료** ack 시각 (ISO-8601). SCHEDULED/PENDING/CANCELED/MISSED 는 null — 실패·폐기 ack 는 채우지 않는다(정상 송출 집계의 단일 소스)
// (감사 #11) 미도달 사유·재시도 진단 4필드 — ack outcome 계약 확장으로 신설.
missedReason?: DispatchHistoryItemMissedReason | null; // "EXPIRED" | "PLAYBACK_FAILED" | "SKIPPED". MISSED 가 아닌 송출은 항상 null
missedAt?: string | null; // MISSED 종결 시각 (ISO-8601). MISSED 가 아니거나 V49 이전 만료 row 면 null
playbackFailureCount: number; // 점장 player 의 재생 실패 보고 누적 횟수(NOT NULL, 기본 0). **status=PENDING 인데 0보다 크면 지금 서버가 재전달 중**(재시도 중)이라는 운영 진단 신호
lastFailedAt?: string | null; // 마지막 실패 보고 시각 (ISO-8601). 실패 보고가 없으면 null. `missedAt` 과 다르다 — 임계치 미만이면 missedAt 은 null 이고 이 값만 채워진다
scheduledAt?: string | null; // (SPEC #078) 예약 송출 시각 (ISO-8601). null=즉시 송출 row · non-null=예약 송출 row(SCHEDULED 또는 디스패처 전이 후 PENDING/PLAYED/CANCELED — 시각 정보 그대로 보존). FE 가 SCHEDULED 행 "송출 시각" 셀에 노출
// (#071) 송출 수행자 actor 정보 — `announcement_dispatch.audit_id` (V28) FK 로 `hq_audit_log` 와 LEFT JOIN 한 스냅샷.
actorEmail?: string | null; // (#071) 송출 수행자 이메일 스냅샷. HQ_MANAGER 면 본사 매니저 본인, OPERATOR_IMPERSONATING 면 위장 대상 본사 매니저. V28 이전 row 또는 audit 누락 시 null
actorRole?: DispatchHistoryItemActorRole;// (#071) "HQ_MANAGER" | "OPERATOR_IMPERSONATING". V28 이전 row 는 null
impersonatedByEmail?: string | null; // (#071) 위장 운영자 이메일 스냅샷. OPERATOR_IMPERSONATING 일 때만 채워짐 — 직접 송출(HQ_MANAGER)·V28 이전 row 는 null
}
DispatchHistoryAggregate {
total: number; // 전체 송출 row 수
played: number; // 재생 완료(PLAYED) row 수
pending: number; // 미재생(PENDING) row 수
scheduled: number; // (SPEC #078) 예약 대기(SCHEDULED) row 수 — 디스패처 전이 전 row 만 카운트
missed: number; // (감사 #11) 미재생 종결(MISSED) row 수 — grace 초과 자동 만료 + 점장 player 의 재생 실패·폐기 보고 합계
canceled: number; // (감사 #11) 본사 취소(CANCELED) row 수 — 도달 의도가 철회된 송출(미도달과 구분)
distinctStoreCount: number; // (SPEC #075) 고유 매장 수 — COUNT(DISTINCT storeId), 매장 0건이면 0
}
// 감사 #11 — 상태 5종 분해가 완성되어 `scheduled + pending + played + missed + canceled == total` 이 성립한다
// (`distinctStoreCount` 는 매장 단위 지표라 이 합에 들어가지 않는다).operationId
listHqTtsAnnouncementDispatches(200). 정렬created_at DESC, id DESC서버 고정 (결정적). 중복 송출 허용 — 같은 매장에 여러 번 송출되면dispatchId가 다른 별개 row 로 row 단위 노출(매장 그룹핑 요약은 후속 F). aggregate 는 현재 페이지가 아니라 그 안내방송 scope 의 전체 카운트 라 페이지 이동에도 같은 숫자다. 본인 본사 활성 HQ 출처 안내방송만 — 타 본사·미존재·삭제·STORE_BROADCAST 출처는 404TTS_ANNOUNCEMENT_NOT_FOUND(존재 은닉). FE 는apps/space/admin/announcements행 [이력] 또는 송출 결과 배너 [이력 보기] →DispatchHistoryDialog(집계 헤더 + 매장 행 표 + 페이지네이션). status 는 기존DispatchStatus(enums) 와 동일 값 도메인 — generated 타입은 endpoint scopeDispatchHistoryItemStatus(value 동일).미도달 사유·재시도 표기 (감사 #11):
DispatchHistoryDialog는 MISSED 행의 pill 문구에 사유를 붙여 미재생(만료) / 미재생(재생 실패) / 미재생(건너뜀) 으로 노출하고(missedReasonnull 이면 “미재생” 폴백, 종결 시각missedAt은 title tooltip),status=PENDING && playbackFailureCount > 0인 행에는 “재시도 중 · 실패 N회” 보조 줄을 붙인다(마지막 실패lastFailedAt은 tooltip). 재시도 정책(3회 누적 시 MISSED(PLAYBACK_FAILED) 종결)은 BE 소유 — FE 는 표시만 하고 판정하지 않는다. 집계 헤더에는missed·canceledchip 이 추가돼 5종 합 ==total로 잔차 없이 분해된다.missedReason값 도메인은 DispatchMissedReason 과 동일 — generated 타입은 endpoint scopeDispatchHistoryItemMissedReason(nullable 이라| null포함).actor 3 필드 nullable 이유 (#071): ①
announcement_dispatch.audit_id는 V28 신규 컬럼이라 V28 이전 row 는 백필 안 함(announcement_id + occurred_at 범위 백필이 부정확) → 해당 row 의 세 필드 모두 null. ②audit_id의 FK 는ON DELETE SET NULL이라 audit row 가 후속 삭제되면 자동으로 null 화. ③ V28 이후 신규 dispatch 는 같은 트랜잭션 내 audit INSERT 가 항상 짝지어지므로(#067 원자성 패턴) 실 운영에서 null 노출은 없음.DispatchHistoryItemActorRole값 도메인은 HqAuditActorRole 과 동일(HQ_MANAGER·OPERATOR_IMPERSONATING) — generated 타입은 endpoint scope.
HqAuditListResponse / HqAuditItem (#067 — GET /api/v1/hq/audit/dispatches)
// generated `schemas/hqAuditListResponse.ts`·`hqAuditItem.ts`·`hqAuditItemAction.ts`·`hqAuditItemActorRole.ts`·`hqAuditItemTargetType.ts`·`listHqAuditDispatchesParams.ts`·`listHqAuditDispatchesAction.ts`
HqAuditListResponse {
items: HqAuditItem[]; // 현재 페이지 본사 감사 행
page: number; // 0-base
size: number; // 1..100 clamp
total: number; // 본사 scope 의 전체 감사 행 수
}
HqAuditItem {
id: string; // 감사 행 id (hq_audit_log.id)
occurredAt: string; // 발생 시각 (ISO-8601 UTC)
actorEmail: string; // 행위자 본사 매니저 이메일 (스냅샷)
actorRole: HqAuditItemActorRole; // "HQ_MANAGER" | "OPERATOR_IMPERSONATING"
impersonatedByEmail?: string | null; // 임퍼소네이션 중인 운영자 이메일 (위장 컨텍스트에서만, 그 외 null)
action: HqAuditItemAction; // "HQ_ANNOUNCEMENT_DISPATCHED" (현재 1종, 후속 확장)
targetType: HqAuditItemTargetType; // "TTS_ANNOUNCEMENT"
targetId?: string | null; // 대상 id (예: 안내방송 id). null 가능
targetLabel?: string | null; // 대상 라벨 스냅샷(안내방송 제목). null 가능
detail?: string | null; // 액션 상세 (예: "target=ALL·count=3·storeIds=[…]"). 1024자 cap
}
ListHqAuditDispatchesParams { // GET /api/v1/hq/audit/dispatches query
from?: string; // 기간 시작 (occurred_at 기준, ISO-8601 date-time)
to?: string; // 기간 종료 (ISO-8601 date-time)
actorAccountId?: string; // 수행 본사 매니저 계정 id (UUID)
action?: ListHqAuditDispatchesAction; // HqAuditItemAction 과 동값 (현재 1종)
targetId?: string; // 대상 id (예: 안내방송 id)
q?: string; // 대상 라벨·상세 부분 검색 (대소문자 무시, ≤100)
page?: number; // 0-base
size?: number; // 1..100 clamp
}operationId
listHqAuditDispatches(200). 정렬occurred_at DESC, id DESC서버 고정(결정적). hqId 는 토큰 주체 도출(자기 본사만 —WHERE hq_id = :hqId강제, 타 본사 격리). impersonation 송출(운영자 위장) 행은actorRole=OPERATOR_IMPERSONATING+impersonatedByEmail동시 노출 — 원본 운영자와 위장 본사 매니저 둘 다 추적.impersonatedByEmail은actorRole=HQ_MANAGER시 null (스키마 nullable). 기록은HqAnnouncementDispatchService.dispatch트랜잭션 내HqAuditService.record— append-only(audit INSERT 실패 시 dispatch 도 함께 롤백, 원자성). FE 는apps/space/admin/audit페이지(필터·페이지네이션 client state, generateduseListHqAuditDispatches). enum 도메인은 HqAuditAction · HqAuditActorRole · HqAuditTargetType.
HqAuditActorListResponse / HqAuditActorOption (#069 — GET /api/v1/hq/audit/actors)
// generated `schemas/hqAuditActorListResponse.ts`·`hqAuditActorOption.ts`·`hqAuditActorOptionRole.ts`
HqAuditActorListResponse {
items: HqAuditActorOption[]; // 본인 본사 audit 등장 actor 옵션 (상한 200)
}
HqAuditActorOption {
accountId: string; // actor 계정 id (UUID — HQ_MANAGER 면 본사 매니저, OPERATOR_IMPERSONATING 이면 원본 운영자)
email: string; // actor 이메일 스냅샷 (같은 actor 가 이메일을 바꿨으면 최신 audit 행 기준)
role: HqAuditActorOptionRole; // "HQ_MANAGER" | "OPERATOR_IMPERSONATING"
occurrenceCount: number; // 해당 actor 의 audit 행 총 수 (정렬·label 보조)
}operationId
listHqAuditActors(200). envelope 없음(상한 200 라 페이지네이션 미적용). 정렬occurrenceCount DESC, email ASC서버 고정 · 상한 200(초과 시 상위만, 모자라면 FE 자유 입력 fallback). 쿼리 =hq_audit_logUNION ALL projection(HQ_MANAGER actor 와 OPERATOR_IMPERSONATING 의 원본 운영자 둘 다 추출) + outer GROUP BY(accountId, role) → email 은 같은 actor 의 최신 audit 스냅샷. hqId 는 토큰 주체 도출(자기 본사만 —WHERE hq_id = :hqId강제, 타 본사 격리). 기존 V27 인덱스idx_hq_audit_actor_account_id재사용(마이그레이션·추가 인덱스 0). FE 는apps/space/admin/audit행위자 select(자유 입력 → select 전환, 옵션 0건이면 disabled + “감사 로그가 없습니다.”). enum 도메인은 HqAuditActorRole 와 같은 값을 공유한다.
HQ Commercial DTOs (#093 — apps/space /admin/commercials)
본사(HQ_MANAGER) 가 CM송(광고/공지 음원) 을 직접 등록·조회·편집·삭제하는 도메인. 가시 범위는 본인
본사의 활성 row 만(WHERE hq_id = :hqId AND deleted_at IS NULL 강제, BE D3). 본사 안내방송(#061) 이
“TTS 합성으로 매장에 송출” 한다면 CM송은 “사전 업로드 음원을 점장 player 가 N곡마다 1회 자동 재생”
하는 다른 도메인이다. #093 은 백본 + 본사 관리 UI 슬라이스이고, 점장 player 사이클 재생은
SPEC #094·#095·#103·#104 에서 완료(재생 모델은 #141 오버레이 동시재생 — 음악은 멈추지 않는다).
CM 라이브러리 묶음만 후속(F4). #174: 등록·교체가 URL 입력 → MP3 파일 업로드로 전환(서버 검증·
blob 저장·audioUrl 자동 채움), audit HQ_COMMERCIAL_CREATED·HQ_COMMERCIAL_FILE_REPLACED 추가.
HqCommercialListItem (#093 — GET /api/v1/hq/commercials item · GET .../{id} 동일 필드)
// generated `schemas/hqCommercialListItem.ts`·`hqCommercialDetailResponse.ts` (동일 필드)
HqCommercialListItem {
id: string;
title: string; // 표시명 (1~200자)
audioUrl: string; // 서버가 파일 업로드(#174) 후 채운 Azure blob URL (`{prefix}/commercials/{id}.mp3`)
durationSeconds: number; // 오디오 길이(초) — 1..3600
isActive: boolean; // 활성 여부 — 점장 player 사이클 재생 대상 토글(#094 라운드로빈 후보 필터)
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}목록·상세 동일 필드(generated 는 응답별로
HqCommercialListItem/HqCommercialDetailResponse두 타입 emit, 필드 값은 같음). FE 목록 행은 제목 셀 클릭으로/admin/commercials/{id}네비 → 상세에서<audio controls src={audioUrl}>로 미리듣기(별도 lazy fetch 불필요 — list item 에 이미 audioUrl 포함, TTS 안내방송 #061 와 다른 점).
HqCommercialListResponse (#093 — 페이지네이션 응답)
// generated `schemas/hqCommercialListResponse.ts`
HqCommercialListResponse {
items: HqCommercialListItem[];
page: number; // 0-base
size: number; // 1..100 clamp
total: number; // 필터 적용 후 전체 건수(soft-delete 제외)
}operationId
listHqCommercials. queryq?·isActive?·page·size. 정렬created_at DESC, id ASC서버 고정(결정적, BE D5). hqId 토큰 주체 도출 — 타 본사 비노출.
ListHqCommercialsParams (#093 — query · generated)
// generated `schemas/listHqCommercialsParams.ts`
ListHqCommercialsParams = {
q?: string; // 제목 부분일치(대소문자 무시), ≤100
isActive?: boolean; // true/false. 미지정 시 전체
page?: number; // 0-base
size?: number; // 1..100 clamp
};CreateHqCommercialBody · CreateHqCommercialParams (#093·#174 — POST /api/v1/hq/commercials, multipart)
// generated `schemas/createHqCommercialBody.ts` (multipart body)
CreateHqCommercialBody {
file: Blob; // CM송 MP3 파일
}
// generated `schemas/createHqCommercialParams.ts` (query)
CreateHqCommercialParams {
title: string; // 1~200
durationSeconds: number; // 1~3600 (클라이언트 추출)
}#174: URL 입력 → 파일 업로드 전환. 구 JSON
CreateHqCommercialRequest{title,audioUrl, durationSeconds}는 OpenAPI 에서 제거됐다. 이제consumes=multipart/form-data—file은 body,title·durationSeconds는 query. 서버가 파일을 검증(MP3 magic byte·audio/mpeg·20MB· 비어있지 않음, ID3 rewrite 미경유 as-is 저장 · #174 D2)한 뒤 Azure blob({prefix}/commercials/{id}.mp3)에 저장하고audioUrl을 채운다.durationSeconds는 FE 가 Web Audio 로 추출(#174 D4). 201 +HqCommercialDetailResponse, 생성 시isActive=true기본. 에러 400 두 종류: 파일 문제(빈· 비-MP3·20MB 초과) =COMMERCIAL_INVALID_FILE, title/durationSeconds 범위 위반 = 전역 검증 400(별개 code). hqId·createdAt·updatedAt 은 BE 가 토큰 주체·now()로 채운다(타 본사 격리). generated 훅useCreateHqCommercial({data:{file}, params:{title,durationSeconds}}).
ReplaceHqCommercialFileBody · ReplaceHqCommercialFileParams (#174 — PUT /api/v1/hq/commercials/{id}/file, multipart)
// generated `schemas/replaceHqCommercialFileBody.ts` (multipart body)
ReplaceHqCommercialFileBody {
file: Blob; // 새 CM송 MP3 파일
}
// generated `schemas/replaceHqCommercialFileParams.ts` (query)
ReplaceHqCommercialFileParams {
durationSeconds: number; // 1~3600 (클라이언트 재추출)
}#174 신규 — 오디오 파일 교체. 음원
PUT /{id}/file미러 — 같은 blob key ({prefix}/commercials/{id}.mp3) 덮어쓰기(orphan 없음)로audioUrl(동일 URL·내용 교체)·durationSeconds갱신. 200 +HqCommercialDetailResponse. 파일 검증은 create 와 동일 — 파일 문제 400COMMERCIAL_INVALID_FILE/ durationSeconds 범위 위반 = 전역 검증 400. 기존 title·isActive 는 불변(PATCH 로 별도 관리). hqId ≠ 주체/미존재/삭제 → 404COMMERCIAL_SONG_NOT_FOUND(은닉). generated 훅useReplaceHqCommercialFile({id, data:{file}, params:{durationSeconds}}). FE 상세 화면 오디오 교체 섹션(파일 선택 + duration 재추출).
UpdateHqCommercialRequest (#093 — PATCH /api/v1/hq/commercials/{id})
// generated `schemas/updateHqCommercialRequest.ts`
UpdateHqCommercialRequest {
title?: string | null; // null = 미변경, non-null 이면 1~200
isActive?: boolean | null; // null = 미변경
}200 +
HqCommercialDetailResponse(부분 갱신 후 전체 응답). 두 필드 모두 null = no-op(현재 상태 그대로 200). 오디오(audioUrl·durationSeconds) 교체는 별도 endpointPUT /{id}/file(#174) — PATCH 는 메타(title·isActive)만. FE 편집 form 은 변경 없으면 [저장] disabled. hqId ≠ 주체/미존재/ 삭제 → 404COMMERCIAL_SONG_NOT_FOUND(존재 은닉, BE D3).
삭제는
deleteHqCommercial(204 soft-delete, body 없음,deleted_at채움). 404COMMERCIAL_SONG_NOT_FOUND(affected=0 — 이미 삭제·미존재·타 본사 모두 은닉). FE 는 상세 마지막 섹션의 2-step 인라인 confirm strip 으로 호출(404=이미 삭제됨 흡수 → 목록 복귀).
HQ Mode CS 티켓 DTOs (#086 — apps/space /admin/support)
본사(HQ_MANAGER) 가 운영사에 문의·요청·이슈를 보고하는 CS 채널. 운영자 ticket 백본(#027) 의 BE
service 를 재사용하되 본사 view 는 별도 endpoint(/api/v1/hq/tickets/*)·DTO 로 분리(운영자
assignee 등 과노출 회피, BE D5 — 같은 SPEC #086). enum 도메인은 운영자 ticket 과 값이 같지만 generated
는 응답별로 enum 을 분리 emit (HqTicketListItemStatus·HqTicketDetailResponseStatus·
CreateHqTicketRequestPriority 등). 운영자 ticket DTO 의 INTERNAL 메모는 본사 응답에 포함되지
않는다 (BE D5 — 운영자 내부 채널 보호).
HqTicketListItem (#086 — GET /api/v1/hq/tickets item)
// generated `schemas/hqTicketListItem.ts`
HqTicketListItem {
id: string;
title: string;
status: HqTicketListItemStatus; // OPEN | IN_PROGRESS | RESOLVED | CLOSED
priority: HqTicketListItemPriority; // URGENT | HIGH | NORMAL | LOW
commentCount: number;
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}본사 view 는 hqName·assigneeEmail 을 노출하지 않는다(자기 본사만 보이므로 hqName 노이즈, assignee
는 운영자 내부 정보). FE 행은 제목·상태 배지·우선순위 배지·댓글 수·작성 시각 KST(createdAt).
HqTicketListResponse (#086 — 페이지네이션 응답)
// generated `schemas/hqTicketListResponse.ts`
HqTicketListResponse {
items: HqTicketListItem[];
page: number; // 0-base
size: number;
total: number;
}정렬은 created_at DESC, id ASC 서버 고정(결정적, BE D7). q(제목 부분일치, ≤100)·status·
priority 필터. WHERE ticket.hq_id = :hqId 강제(타 본사 격리).
ListHqTicketsParams (#086 — query · generated ListHqTicketsStatus/ListHqTicketsPriority, operationId listHqTickets)
// generated `schemas/listHqTicketsParams.ts`
ListHqTicketsParams {
q?: string; // @maxLength 100 — 제목 부분일치(대소문자 무시)
status?: ListHqTicketsStatus; // OPEN | IN_PROGRESS | RESOLVED | CLOSED
priority?: ListHqTicketsPriority; // URGENT | HIGH | NORMAL | LOW
page?: number; // 0-base
size?: number; // 1..100 clamp
}apps/space HqTicketListClient 가 draft↔applied 분리(공용 ListToolbar 패턴) — 적용 시 page=0
리셋. FE 는 generated enum 값을 화이트리스트로 sanitize(공유 URL·미동기 enum 폴백).
HqTicketDetailResponse (#086 — GET /api/v1/hq/tickets/{id} 200 · POST /api/v1/hq/tickets 201)
// generated `schemas/hqTicketDetailResponse.ts`
HqTicketDetailResponse {
id: string;
title: string;
body: string; // 본문(작성 시 입력, 최대 5000자)
status: HqTicketDetailResponseStatus; // 동일 enum
priority: HqTicketDetailResponsePriority;
submitterEmail: string; // 본사 매니저 이메일 스냅샷(BE D6)
createdAt: string;
updatedAt: string;
comments: HqTicketCommentItem[]; // 생성순 오름차순(시간순)
attachments: TicketAttachmentItem[]; // 생성순 오름차순(없으면 []) — visibility 필드 없음
}타 본사 ticket id 도 404 TICKET_NOT_FOUND(존재 은닉, BE D3). FE 4 섹션(헤더·메타·본문·댓글). 본문은
pre-wrap(공백·줄바꿈 보존). 작성 응답(201)도 같은 DTO — 작성 직후 comments=[].
HqTicketCommentItem (#086 — 댓글 스레드 item · POST .../comments 201)
// generated `schemas/hqTicketCommentItem.ts`
HqTicketCommentItem {
id: string;
body: string; // @maxLength 5000
authorRole: HqTicketCommentItemAuthorRole; // HQ_MANAGER | OPERATOR
authorEmail: string; // 작성자 이메일 스냅샷
createdAt: string; // ISO-8601
}운영자 TicketCommentItem.kind=INTERNAL 메모는 본사 응답에 포함되지 않음(BE D5 — 별도 DTO).
authorRole 시각 매핑: HQ_MANAGER=“내 매장”(info 톤·본사 매니저), OPERATOR=“운영사”(success 톤·
운영사 응답의 긍정 신호).
CreateHqTicketRequest (#086 — POST /api/v1/hq/tickets)
// generated `schemas/createHqTicketRequest.ts`
CreateHqTicketRequest {
title: string; // @maxLength 200 · NotBlank
body: string; // @maxLength 5000 · NotBlank
priority?: CreateHqTicketRequestPriority; // @nullable — 누락 시 NORMAL default (BE D1)
}hqId·submitterAccountId 는 토큰 주체 도출(요청 본문에 직접 지정 불가 — 타 본사 격리, BE D6). FE
apps/space /admin/support/new 폼은 priority 를 LOW/NORMAL/HIGH 3종만 노출(URGENT 는
운영자 판단, F 후속) — backend enum 자체는 4종이라 후속 도입 시 옵션만 1줄 추가하면 된다.
AddHqTicketCommentRequest (#086 — POST /api/v1/hq/tickets/{id}/comments)
// generated `schemas/addHqTicketCommentRequest.ts`
AddHqTicketCommentRequest {
body: string; // @maxLength 5000 · NotBlank
}응답 201 은 HqTicketCommentItem 1건. FE 는 성공 후 getGetHqTicketDetailQueryKey(id) invalidate
→ 상세 refetch 로 새 댓글이 스레드 끝에 자동 추가됨(생성순 보장).
HqTicketStatusChangeRequest / Response (#108 — PATCH /api/v1/hq/tickets/{id}/status)
// generated `schemas/hqTicketStatusChangeRequest.ts` · `hqTicketStatusChangeResponse.ts`
HqTicketStatusChangeRequest {
status: HqTicketStatusChangeRequestStatus; // OPEN | IN_PROGRESS | RESOLVED | CLOSED
}
HqTicketStatusChangeResponse {
status: HqTicketStatusChangeResponseStatus; // 변경 후 status
}본사 허용 전이는 RESOLVED→CLOSED·RESOLVED→IN_PROGRESS·CLOSED→IN_PROGRESS 만(운영자 전이표
부분집합). 비허용 전이 → 409 TICKET_INVALID_STATUS_TRANSITION. FE 는 성공 후
getGetHqTicketDetailQueryKey(id) invalidate.
HqTicketPriorityChangeRequest / Response (#108 — PATCH /api/v1/hq/tickets/{id}/priority)
// generated `schemas/hqTicketPriorityChangeRequest.ts` · `hqTicketPriorityChangeResponse.ts`
HqTicketPriorityChangeRequest {
priority: HqTicketPriorityChangeRequestPriority; // URGENT | HIGH | NORMAL | LOW
}
HqTicketPriorityChangeResponse {
priority: HqTicketPriorityChangeResponsePriority; // 변경 후 priority
}본사는 LOW/NORMAL/HIGH 만 설정 가능 — URGENT 요청 시 400 HQ_TICKET_PRIORITY_FORBIDDEN
(req enum 은 URGENT 포함이나 BE 가 단일 방어선, FE 세그먼트는 3단계만 노출). 동일값 멱등 200.
HQ CS Ticket enums (#086 · #108)
| enum | 값 | 비고 |
|---|---|---|
HqTicketListItemStatus (= detail) | OPEN · IN_PROGRESS · RESOLVED · CLOSED | 운영자 TicketStatus 와 값 일치 — generated 만 분리 emit |
HqTicketListItemPriority (= detail · CreateHqTicketRequestPriority) | URGENT · HIGH · NORMAL · LOW | 운영자 TicketPriority 와 값 일치 |
HqTicketCommentItemAuthorRole | HQ_MANAGER · OPERATOR | 본사 view 전용 — INTERNAL 없음(BE D5) |
ListHqTicketsStatus/Priority | 동값 | query 파라미터 enum (orval 분리 emit) |
HqTicketStatusChangeRequestStatus/ResponseStatus (#108) | OPEN · IN_PROGRESS · RESOLVED · CLOSED | 전 enum req/resp 분리 emit. 정책 허용 전이는 부분집합(BE 검증) |
HqTicketPriorityChangeRequestPriority/ResponsePriority (#108) | URGENT · HIGH · NORMAL · LOW | req 는 전값 포함하나 URGENT 는 400 거부(BE 단일 방어선). FE 는 3단계만 |
HQ 하위 매장 CS DTOs (#173 — apps/space /admin/store-support)
본사가 산하 매장 점장이 올린 CS(#112, storeId≠null)를 조회·처리하는 별도 DTO 군이다. 위 HQ CS
티켓 DTOs(#086, /api/v1/hq/tickets/*)는 본사↔운영사 CS(본사 본인 발신)이고, 여기는 본사→하위 매장
CS(/api/v1/hq/store-tickets/*)로 도메인이 다르다. 처리 정책(#173 D1): 본사가 조회·답변(REPLY)·상태
변경(표준 전이 전체) 가능, assign·우선순위 조정은 운영사 전담(해당 endpoint·DTO 없음). 매장명·작성
점장 이메일이 노출되고, 댓글은 3-role(STORE_MANAGER·OPERATOR·HQ_MANAGER)이며 운영자 INTERNAL 메모는
제외된다. category 는 nullable(| null — 기존 운영자/본사 티켓 하위호환, 점장 작성은 필수).
HqStoreTicketListItem / HqStoreTicketListResponse (#173 — GET /api/v1/hq/store-tickets)
// generated `schemas/hqStoreTicketListItem.ts` · `hqStoreTicketListResponse.ts`
HqStoreTicketListItem {
id: string;
title: string;
status: HqStoreTicketListItemStatus; // OPEN | IN_PROGRESS | RESOLVED | CLOSED
priority: HqStoreTicketListItemPriority; // URGENT | HIGH | NORMAL | LOW
category?: HqStoreTicketListItemCategory; // @nullable — PLAYBACK | BROADCAST | BILLING | ACCOUNT | OTHER | null
storeId: string; // 작성 매장 id
storeName: string; // 작성 매장명
commentCount: number;
createdAt: string; // ISO-8601
updatedAt: string;
}
HqStoreTicketListResponse { items: HqStoreTicketListItem[]; page: number; size: number; total: number; }정렬 created_at DESC, id ASC. WHERE storeId≠null AND store.hq_id=:hqId(hqId 격리). FE 표 5컬럼:
매장명(+제목)·카테고리(nullable → ”—”)·상태 배지·우선순위 배지·작성 시각 KST. 카테고리 라벨은
점장 CS(#112)와 동일(재생·음악/방송·안내/결제·정산/계정·로그인/기타).
HqStoreTicketDetailResponse / HqStoreTicketCommentItem (#173 — GET /api/v1/hq/store-tickets/{id})
// generated `schemas/hqStoreTicketDetailResponse.ts` · `hqStoreTicketCommentItem.ts`
HqStoreTicketDetailResponse {
id: string; title: string; body: string;
status: HqStoreTicketDetailResponseStatus;
priority: HqStoreTicketDetailResponsePriority;
category?: HqStoreTicketDetailResponseCategory; // @nullable
storeId: string; storeName: string;
submitterEmail: string; // 작성 점장 이메일 스냅샷
createdAt: string; updatedAt: string;
comments: HqStoreTicketCommentItem[]; // 생성순 오름차순, REPLY 만
attachments: TicketAttachmentItem[]; // 운영자 INTERNAL 첨부 제외 · visibility 필드 없음
}
HqStoreTicketCommentItem {
id: string; body: string; // @maxLength 5000
authorRole: HqStoreTicketCommentItemAuthorRole; // STORE_MANAGER | OPERATOR | HQ_MANAGER
authorEmail: string; createdAt: string;
}타 본사·타 매장(hqId 미소속)·미존재 전부 404 TICKET_NOT_FOUND(은닉). 운영자 INTERNAL 메모 제외(BE
필터). FE 채팅 스레드 3-role 시각: STORE_MANAGER=“점장”(좌·surface) · OPERATOR=“운영사”(좌·success 틴트) ·
HQ_MANAGER=“본사”(우·primary-soft — 나=처리 주체).
HqStoreTicketStatusChangeRequest / Response (#173 D1 — PATCH /api/v1/hq/store-tickets/{id}/status)
// generated `schemas/hqStoreTicketStatusChangeRequest.ts` · `...Response.ts`
HqStoreTicketStatusChangeRequest { status: HqStoreTicketStatusChangeRequestStatus; } // 표준 전이 전체
HqStoreTicketStatusChangeResponse { status: HqStoreTicketStatusChangeResponseStatus; }본사가 처리 주체 → 표준 전이 전체 허용(OPEN→IN_PROGRESS→RESOLVED→CLOSED + RESOLVED/CLOSED→IN_PROGRESS).
비허용 전이 409 TICKET_INVALID_STATUS_TRANSITION. FE 는 성공 후 getGetHqStoreTicketDetailQueryKey(id)
invalidate. 우선순위 변경 DTO/endpoint 는 없다(운영사 전담).
AddHqStoreTicketCommentRequest (#173 — POST /api/v1/hq/store-tickets/{id}/comments)
// generated `schemas/addHqStoreTicketCommentRequest.ts`
AddHqStoreTicketCommentRequest { body: string; } // @maxLength 5000 · NotBlank → 201 HqStoreTicketCommentItem(authorRole=HQ_MANAGER)HqStoreSupportUnreadSignalResponse (#173 — GET /api/v1/hq/store-support/unread-signal)
// generated `schemas/hqStoreSupportUnreadSignalResponse.ts`
HqStoreSupportUnreadSignalResponse {
openOrInProgressCount: number; // 산하 매장 미해결(OPEN+IN_PROGRESS) 수 — 미해결 count 배지
latestStoreActivityAt?: string | null; // 최신 점장 활동(신규 티켓·신규 답글) 시각 — 새 활동 dot
}FE HQSidebar /admin/store-support(매장 문의) 항목: openOrInProgressCount count 배지(항상 노출) +
latestStoreActivityAt 을 localStorage lastSeen(lm.support.lastSeen.hq-store.<hqId>)과 비교한 새 활동
dot. 60초 폴링(refetchIntervalInBackground:false — 탭 비활성 중단·focus 복귀 시 즉시). 목록 mount 시
lastSeen=now(markSupportSeen("hq-store", hqId)).
HQ 하위 매장 CS enums (#173)
| enum | 값 | 비고 |
|---|---|---|
HqStoreTicketListItemStatus (= detail · statusChange req/resp) | OPEN · IN_PROGRESS · RESOLVED · CLOSED | 표준 전이 전체(본사=처리 주체) |
HqStoreTicketListItemPriority (= detail) | URGENT · HIGH · NORMAL · LOW | 조회만(변경은 운영사 전담) |
HqStoreTicketListItemCategory (= detail) | PLAYBACK · BROADCAST · BILLING · ACCOUNT · OTHER · null | 타입에 | null 포함 — FE Record 키는 NonNullable 파생 |
HqStoreTicketCommentItemAuthorRole | STORE_MANAGER · OPERATOR · HQ_MANAGER | 3-role(점장 발신·운영사·본사). INTERNAL 없음 |
ListHqStoreTicketsStatus/Priority/Category | 동값 | query 파라미터 enum |
Store Mode CS 티켓 DTOs (#112 — apps/space /store/support)
점장(STORE_MANAGER) self-service CS 채널. 본사 CS(#086)와 동일하게 운영자 ticket 백본을 재사용하되
store 격리 service + view DTO 분리로 과노출을 차단한다. 본사와 달리 우선순위는 view 에서 제외
(점장 미노출·서버 NORMAL 고정), category 신규 노출(작성 시 필수), 댓글은 kind=REPLY 만(INTERNAL
메모 비노출). orval 은 응답별 enum 분리 emit.
StoreTicketListItem (#112 — GET /api/v1/store/tickets item)
// generated `schemas/storeTicketListItem.ts`
StoreTicketListItem {
id: string;
title: string;
status: StoreTicketListItemStatus; // OPEN | IN_PROGRESS | RESOLVED | CLOSED
category?: StoreTicketListItemCategory; // PLAYBACK | BROADCAST | BILLING | ACCOUNT | OTHER (nullable — 공용 테이블)
commentCount: number;
createdAt: string; // ISO-8601
updatedAt: string;
}우선순위 필드 없음(본사 item 과 차이). category? 는 공용 ticket 테이블이 nullable 이라 옵셔널 —
점장 작성 행은 항상 채워지지만 레거시/타 채널 행은 미설정일 수 있어 FE 는 ”—” 폴백.
StoreTicketListResponse / ListStoreTicketsParams (#112)
StoreTicketListResponse { items: StoreTicketListItem[]; page; size; total; }
// generated `schemas/listStoreTicketsParams.ts`, operationId `listStoreTickets`
ListStoreTicketsParams {
q?: string; // 제목 부분검색 ≤100
status?: ListStoreTicketsStatus; // OPEN | IN_PROGRESS | RESOLVED | CLOSED
category?: ListStoreTicketsCategory; // PLAYBACK | BROADCAST | BILLING | ACCOUNT | OTHER
page?: number; // 0-base
size?: number; // 1..100 clamp
}apps/space StoreTicketListClient 가 draft↔applied 분리(공용 ListToolbar) — 적용 시 page=0.
StoreTicketDetailResponse (#112 — GET /api/v1/store/tickets/{id} 200 · POST /api/v1/store/tickets 201 · PATCH .../{id}/close 200)
// generated `schemas/storeTicketDetailResponse.ts`
StoreTicketDetailResponse {
id: string;
title: string;
body: string;
status: StoreTicketDetailResponseStatus;
category?: StoreTicketDetailResponseCategory;
createdAt: string;
updatedAt: string;
comments: StoreTicketCommentItem[]; // 생성순 오름차순, REPLY 만
attachments: TicketAttachmentItem[]; // 운영자 INTERNAL 첨부 제외 · visibility 필드 없음
}본사 detail 의 priority·submitterEmail 없음(점장 view 미노출). 우선순위는 서버 NORMAL 고정이라
점장에게 의미 없고, 작성자는 본인 매장이라 이메일 노출 불필요.
SPEC #121 확인 종료(PATCH .../{id}/close)도 이 DTO 를 read-back 으로 재사용한다(별도 응답 DTO 없음 —
종료 직후 status=CLOSED 로 갱신된 상세를 200 으로 반환). 점장은 RESOLVED→CLOSED 1종만 — 비-RESOLVED →
409 TICKET_INVALID_STATUS_TRANSITION, 미존재·타 매장 → 404 TICKET_NOT_FOUND.
StoreTicketCommentItem (#112 — 댓글 스레드 item · POST .../comments 201)
// generated `schemas/storeTicketCommentItem.ts`
StoreTicketCommentItem {
id: string;
body: string;
authorRole: StoreTicketCommentItemAuthorRole; // STORE_MANAGER | OPERATOR
createdAt: string;
}본사 comment 의 authorEmail 없음(점장 view 단순화). INTERNAL 메모는 노출되지 않는다(BE D3).
CreateStoreTicketRequest / AddStoreTicketCommentRequest (#112)
// generated `schemas/createStoreTicketRequest.ts`
CreateStoreTicketRequest {
title: string; // 1~200, NotBlank
body: string; // 1~5000, NotBlank
category: CreateStoreTicketRequestCategory; // 필수(@NotNull) — 본사 priority? 와 달리 nullable 아님
}
AddStoreTicketCommentRequest { body: string; } // 1~5000storeId·hqId·priority·status 는 요청 본문에 없다 — 토큰 주체 도출 + 서버 고정(OPEN·NORMAL·hq_id=null).
댓글 추가 응답 201 은 StoreTicketCommentItem 1건. FE 는 성공 후 getGetStoreTicketDetailQueryKey(id) invalidate.
Store CS Ticket enums (#112)
| enum | 값 | 비고 |
|---|---|---|
StoreTicketListItemStatus (= detail) | OPEN · IN_PROGRESS · RESOLVED · CLOSED | 운영자 TicketStatus 와 값 일치 — generated 만 분리 emit |
StoreTicketListItemCategory (= detail · CreateStoreTicketRequestCategory · ListStoreTicketsCategory) | PLAYBACK · BROADCAST · BILLING · ACCOUNT · OTHER | 신규 TicketCategory — 공용 테이블 nullable, 점장 작성 시 필수 |
StoreTicketCommentItemAuthorRole | STORE_MANAGER · OPERATOR | 점장 view 전용 — INTERNAL 없음(BE D3) |
ListStoreTicketsStatus | 동값 | query 파라미터 enum (orval 분리 emit) |
StoreSupportUnreadSignalResponse (#119 F5 — GET /api/v1/store/support/unread-signal)
// generated `schemas/storeSupportUnreadSignalResponse.ts`
{
latestOperatorReplyAt?: string | null; // 본인 매장 ticket 의 운영자 REPLY max createdAt(없으면 null)
}operationId getStoreSupportUnreadSignal(D2 dot). 점장은 카운트 불요 — dot(boolean)만. store_id 격리. FE 는 이 값을 localStorage lastSeen(lm.support.lastSeen.store.<storeId>)과 비교해 dot 판정(F4 헬퍼 재사용). 점장 안내방송 배지는 만들지 않는다(D3 — player 가 PENDING 을 이미 자동 소비, 중복). generated 훅 useGetStoreSupportUnreadSignal. apps/space 점장 player 헤더 [고객지원] Link dot(60초 폴링, 안내방송 20초와 별개 query).
PendingAnnouncementsResponse / PendingAnnouncementItem (송출 슬라이스 — GET /api/v1/store/announcements/pending)
// generated `schemas/pendingAnnouncementsResponse.ts`·`pendingAnnouncementItem.ts`
PendingAnnouncementsResponse {
items: PendingAnnouncementItem[]; // 본인 매장의 미재생(PENDING) 송출 — created_at ASC
revokedDispatchIds: string[]; // (SPEC #077 확장) 본사 원격 즉시중단(revoke) 신호 — 오늘 KST 윈도우 본인 매장에서 본사가 revoke 한 dispatchId 목록. revoke 시 CANCELED 전이로 items 에선 자동 제외되므로 별도 신호로 노출. player 가 재생 중 멘트의 dispatchId 가 이 목록에 있으면 즉시 중단(best-effort, ack 안 함)·재선출 차단.
}
PendingAnnouncementItem {
dispatchId: string; // 송출 id (ack 시 사용)
announcementId: string; // 안내방송 id
title: string; // 안내방송 제목
audioUrl: string; // 합성 오디오 재생 URL (Azure public blob)
durationSeconds?: number | null; // 오디오 길이(초), null 가능
isEmergency: boolean; // (SPEC #082) 긴급방송 여부 — 점장 즉시방송 옵션. 본 슬라이스는 노출만(player 인터럽트 F1·audit 누적 F2 후속).
playAt?: string | null; // (SPEC #178) 절대 재생 시각(ISO-8601) — 전 기기가 이 시각에 동시에 재생 시작. null 이면 종전대로 받는 즉시 재생.
}
// query (generated `listStorePendingAnnouncementsParams.ts`)
ListStorePendingAnnouncementsParams {
deviceId?: string; // (SPEC #178) 이 요청을 보내는 기기 id. 주면 "이 기기가 아직 재생하지 않은 방송"까지 포함(전 기기 재생 보장). 생략 = 종전 매장 단위
}
playAt(SPEC #178) — 예약 방송·본사 즉시방송은 매장의 모든 기기에서 동시에 나가야 하는데, 각 기기가 20초 폴링으로 독립 수신해 최대 20초까지 어긋났다(스피커가 분리된 매장에서 에코처럼 겹친다). 서버가 절대 시각을 지정하고 각 기기가 큐 응답serverNowIso로 계산한 서버-클라 오프셋을 보정해 그 시각에 시작한다(목표 시각 전 오디오 프리페치 — 기대 오차 수십 ms). 현재 값을 채우는 경로는 본사 단건 송출(dispatchHqTtsAnnouncement) 하나다 — 즉시 송출 = 발행 + 2초(IMMEDIATE_PLAY_LEAD— 전 기기가 통지받고 오디오를 준비할 여유) · 예약 송출 =scheduledAt그대로. 본사 반복 예약 전개 (OccurrenceMaterializer)와 점장 송출(즉시·예약·[즉시 방송])은play_at을 채우지 않아 null 이며 받는 즉시 재생한다(점장 즉시방송은 누른 기기 1대만이라 애초에 대상이 아니다 — D8). 과거 시각이면 즉시 재생한다. ⚠️ 현재 FE 는playAt을 소비하지 않는다(후속 슬라이스) — 도착 즉시 재생하는 종전 동작 그대로다.
deviceId기기 인지 조회 (SPEC #178) — dispatch 는 매장 단위 1 row 이고 기기는 20초 주기로 폴링한다. 기기 A 가 먼저 폴링해 10~30초짜리 멘트를 다 틀고 ack 하면 dispatch 가 그 자리에서 PLAYED 로 종착해, 20초 뒤 폴링한 기기 B·C 는status=PENDING조건에 걸려 그 방송을 영영 못 봤다.deviceId를 주면 PENDING 에 더해 최근 5분(DEVICE_FANOUT_GRACE) 안에 PLAYED 된 방송까지 보되 이 기기가 이미 ack 한 방송은 제외한다. 생략하면 응답이 한 바이트도 달라지지 않는다(D12 하위호환).
operationId
listStorePendingAnnouncements(200). 본인 매장의 PENDING 송출만(created_at ASC).apps/space/storeplayer 가 20초 폴링해 도착 즉시 오버레이로 동시 재생(음악은 더킹). ack 은ackStoreAnnouncement(204·멱등) — 선택 본문AckAnnouncementRequest{ outcome? }로 재생 결과를 보고한다(아래). ⚠️FAILED를 보고한 dispatchId 는 서버 재시도 정책에 따라 이 pending 목록에 다시 나타날 수 있다(임계치 미만이면 PENDING 유지). player 가 실패한 id 를 로컬 영구 블랙리스트로 막으면 그 재전달이 통째로 무력화되므로, 영구 차단 대신 짧은 쿨다운만 건다(감사 #11 —finishedAnnouncementsRef는 종착(PLAYED·SKIPPED·revoke)에만 쓴다).revokedDispatchIds(SPEC #077 확장)는 본사 원격 즉시중단 (revoke) 신호로, player 가 재생 중 멘트 즉시 중단·재선출 차단에 쓴다(ack 안 함, Store Player).DispatchStatusenum(enums).
AckAnnouncementRequest (감사 #11 — POST /api/v1/store/announcements/{dispatchId}/ack 선택 본문)
// generated `schemas/ackAnnouncementRequest.ts`·`ackAnnouncementRequestOutcome.ts`
AckAnnouncementRequest {
outcome?: AckAnnouncementRequestOutcome | null; // "PLAYED" | "FAILED" | "SKIPPED". 생략(또는 본문 자체 생략) = PLAYED(하위 호환)
deviceId?: string | null; // (SPEC #178) 이 재생을 수행한 기기 id. 주면 결과가 dispatch_device_ack 에 기기별로 기록. 생략 = 종전 매장 단위 ack
signalSource?: AckAnnouncementRequestSignalSource | null; // (SPEC #180 계측) "SSE" | "POLLING" — 그 송출을 알게 된 채널. 생략 = 미보고(NULL). 알 수 없는 값도 400 이 아니라 미보고로 흡수
receivedAt?: string | null; // (SPEC #180 계측) 신호를 받은 시각(ISO-8601, 서버 시각 보정값). 서버가 signal_latency_ms 계산에 사용
startedAt?: string | null; // (SPEC #180 계측) 실제 재생을 시작한 시각(ISO-8601, 서버 시각 보정값). playAt 대비 편차 실측용
}
deviceId기기별 ack (SPEC #178) — ack 은 원자 조건부 UPDATE(WHERE status='PENDING')라 한 매장의 여러 기기가 같은 방송을 재생하면 첫 기기만 204 를 받고 나머지는 404 였다(실제로는 다 재생했는데 이력엔 1건, 어느 기기가 실패했는지도 알 수 없었다).deviceId를 주면 결과가dispatch_device_ack(PK(dispatch_id, device_id))에 기기별로 남고, dispatch 자체는 매장 단위로 유지된다(본사 송출 이력 UI 변경 최소화). 종착 정책:
상황 dispatch 처리 한 대라도 PLAYEDdispatch PLAYED — 그 방송은 매장에 들린 것이다. 이미 다른 기기가 PLAYED 로 바꿔놔 affected=0 이어도 404 로 만들지 않는다(이 기기의 재생은 실제로 성공했고, 404 면 FE 가 실패로 오인해 재시도·오보고한다) 활성 기기 전부가 FAILED/SKIPPED보고(성공 0)기존 실패 경로 — FAILED 는 누적 후 임계치에서 MISSED( PLAYBACK_FAILED) · SKIPPED 는 MISSED(SKIPPED)아무도 ack 하지 않음 기존 도래+grace(10분) 만료 cron 이 MISSED 로 종결(변경 없음) 즉 꺼져 있는 기기 한 대 때문에 방송이 “미도달” 로 잡히지 않는다. 타 매장·회수된 기기 id 는 매장 단위 ack 로 처리한다.
점장 player 가 재생 결과를 보고하는 유일한 채널. 종전에는 결과와 무관하게 PLAYED 로 확정해 재생되지 않은 방송(긴급 포함)이 본사 리포트에 “정상 송출” 로 잡혔다(감사 #11).
outcome player 경로 서버 처리 PLAYED오버레이 onEndedPENDING→PLAYED · played_at기록SKIPPED시작 시 backlog drain(재생 시도 없이 폐기) 즉시 PENDING→MISSED( missedReason=SKIPPED)FAILED오버레이 onError· 워치독 강제 종료playback_failure_count+1 ·last_failed_at갱신. 3회 미만이면 PENDING 유지 → 다음 폴링에 재전달 · 3회째에 PENDING→MISSED(missedReason=PLAYBACK_FAILED)재시도 정책은 서버 소유 — FE 는 자체 재시도 카운터를 두지 않고 실패할 때마다 그대로
FAILED를 보고한다. 응답은 종착이든 재전달 대기든 동일한 204 라 클라이언트가 상태를 판단할 수단도 없다 (다음 폴링에 다시 내려오면 재생하면 된다). 무한 재전달은 도래+grace(10분) 자동 만료가 상한을 잡는다. 종착 이후의 재전송은 outcome 과 무관하게 404DISPATCH_NOT_FOUND— 먼저 기록된 결론을 덮어쓰지 않는다. 긴급 안내방송은 backlog drain 대상에서 제외되어 SKIPPED 로 폐기되지 않는다(시작 직후 재생 — 감사 #11).
계측 3필드 (SPEC #180 D10) —
signalSource·receivedAt·startedAt은 순수 계측이며 전부 선택이다. 서버는 ack 커밋 이후 별도 트랜잭션에서announcement_dispatch의signal_source·signal_received_at·playback_started_at에 원값을 저장하고signal_latency_ms = signal_received_at − COALESCE(scheduled_at, created_at)를 확정 계산한다(즉시 송출은 row 생성이 곧 발행, 예약은 도래 시각이 발행). 클라 시각이 30초 skew·1시간 상한을 벗어나면 지연만 NULL 로 두고 원값은 보존한다. 알 수 없는 enum·형식 오류·파싱 실패는 400 이 아니라 미보고(NULL) 로 흡수한다 — 계측이 재생 결과 보고를 실패시키면 안 되기 때문이다(같은 이유로 계측 UPDATE 가 실패해도 ack 는 이미 204 로 커밋돼 있다). FE 는signalSource를 (a) SSE 신호 직후 5초 창 안이고 (b) 그 응답이 신호보다 늦게 완료됐으며 (c) 그 응답에서 처음 관측된 송출에만SSE로 귀속한다(겹친 폴링 tick 이 실어 온 항목의 오라벨 차단). 시각은 큐 응답serverNowIso앵커로 보정해 보내며(SSE 귀속분의receivedAt은 신호 시점의 서버 시각으로 역산), 앵커 미동기·backlog drain(SKIPPED) 이면 시각을 싣지 않고 dispatch 당 1회만 보고한다(재전달 ack 의 중복 집계 방지) (Store Player §실시간 통지). 생략하면 종전과 완전히 동일하며 백필도 없다 — NULL 은 “폴링으로 받음”·“지연 0” 이 아니라 “보고하지 않음” 이다.
StreamTicketResponse / StreamStoreAnnouncementsParams (SPEC #180 — 실시간 통지 스트림 티켓)
// generated `schemas/streamTicketResponse.ts`·`streamStoreAnnouncementsParams.ts`
StreamTicketResponse { // POST /api/v1/store/announcements/stream-ticket (200)
ticket: string; // 서명된 단기 스트림 티켓(JWT). streamUrl 에 이미 포함 — 직접 조립이 필요한 경우를 위한 원값
streamUrl: string; // 구독할 SSE 스트림의 **완전한 절대 URL**(티켓 포함). 그대로 EventSource 에 넘긴다
expiresInSeconds: number; // 티켓 수명(초, 기본 60). 만료 티켓으로는 연결 불가 — **재연결마다 새로 발급**
}
StreamStoreAnnouncementsParams { // GET /api/v1/store/announcements/stream
ticket?: string; // 스트림 티켓. **미지정 시 401**(400 이 아니다 — 스트림 응답에 에러 envelope 를 실을 수 없어 상태코드가 계약)
}
streamUrl을 서버가 내려주는 이유 — FE 가 백엔드 base URL 을 수기로 조립하지 않게 하기 위함이다 (BFF non-generated 경로 수기 조립은 이 레포에서 프로덕션 사고를 냈다).apps/spaceplayer 는new EventSource(streamUrl)로 브라우저 → 백엔드 직접 연결한다 — BFF 스트리밍 프록시가 아니다 (VercelmaxDuration강제 재연결·in-flight 스트림의 Provisioned Memory 상시 과금 회피). 티켓은 HS256 stateless JWT 로UPLOAD_TICKET_SECRET을 재사용하되type=stream-ticketclaim 으로 업로드 티켓과 상호 사용이 차단된다(새 env 0 · DB·마이그레이션 0). 티켓 수명(60초)과 커넥션 수명(30분)은 무관하다 — 티켓은 연결 시점에 1회만 검증된다. 발급 실패(429·503)·연결 실패는 전부 폴링 안전망으로 조용히 폴백한다(Store Player §실시간 통지).
PlaybackReportRequest / PlaybackLogRequest / PlaybackReportResponse (#172 FE-A — POST /api/v1/store/playback/report)
// generated `schemas/playbackReportRequest.ts`·`playbackLogRequest.ts`·`playbackReportResponse.ts`
PlaybackReportRequest {
state: PlaybackReportRequestState; // "PLAYING" | "PAUSED" | "SILENT". ⚠️ OFFLINE 은 보고 불가(400 PLAYBACK_INVALID_STATE) — 서버가 staleness 로 파생
currentMusicId?: string | null; // 현재(마지막) 재생 곡 id. 무음/일시정지엔 null. 실존 안 하면 null 정규화
logs?: PlaybackLogRequest[] | null; // 완료/전환된 곡 로그(선택, @maxItems 200). idle heartbeat 은 비우고 곡 전환 시에만 채운다
deviceId?: string | null; // (SPEC #178) 이 보고를 보내는 기기 id. 주면 상태·로그가 기기별로 기록. 생략 = 종전 매장 단위
}
PlaybackLogRequest {
musicId: string; // 재생한 곡 id
libraryId?: string | null; // 재생 컨텍스트 라이브러리 id(선택). 실존 안 하면 null 정규화
startedAt: string; // 재생 시작 시각(ISO-8601). 멱등 키의 일부
playedMs: number; // 실제 재생 밀리초(부분 재생·스킵 포함). 0..86400000
cacheHit?: boolean | null; // (SPEC #179 D10) 이 곡을 로컬 캐시(Cache Storage)에서 재생했는지. 생략(구버전) = null 저장 — 적중률 모수에서 제외(false 로 접지 않음)
}
PlaybackReportResponse {
recordedLogCount: number; // 이번 호출 신규 적재 로그 수(멱등 skip·미등록 곡 skip 제외). heartbeat 은 항상 반영
}operationId
reportStorePlayback(200). 본사 재생 가시성(FE-B)의 적재 채널. 매 호출이store_playback_status(매장당 1행) upsert(state·currentMusicId·last_heartbeat_at=now).logs가 있으면 각 곡의music.musicSource(TRUST/AI)로is_trust를 파생해play_log(전량·신탁 신고 근거) insert +playback_daily_rollup(매장×일 KST, 신탁/비신탁 분리) 가산. 멱등 — 같은(store, musicId, startedAt)로그는 재전송이어도 1회만 적재(중복 skip·롤업 이중 가산 없음). 미등록 곡 id 로그는 무시하고 나머지 계속 적재(부분 성공). storeId·hqId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅useReportStorePlayback.
apps/space/storeplayeruseStorePlaybackReport(apps/space/src/app/store/use-store-playback-report.ts) 가 소비 — fire-and-forget side-channel 로 오디오 파이프라인(<audio>·크로스페이드·더킹·워치독)을 await·차단·throw 로 방해하지 않는다(report 실패·네트워크 오류를 전부 삼켜 매장 음악 끊김 방지 — 핵심 불변식, 테스트로 명시 검증). 보고 트리거: (a) 곡 전환/종료 — 완료 곡 로그를 실어 report + idle 타이머 리셋(정상 재생 중엔 이게 liveness 를 겸해 idle report 가 거의 안 뜸), (b) 상태 전환(PLAYING↔PAUSED↔ SILENT) 즉시 report(대시보드 즉응), (c) idle 5분 타이머 — 곡 안 바뀐 채 마지막 신호 이후 5분이면 state-only report(setTimeout 기반이라 탭 hidden 백그라운드에서도 발화). 네트워크 실패 시 그 report 의 logs 를 로컬 큐 앞으로 되돌려 다음 report 에 합류(큐 상한 200 — 오래된 것부터 버림).started(사용자 제스처 재생 시작) 전엔 보고하지 않음 — 시작 안 한 무인 단말은 신호가 없어 서버가 OFFLINE 으로 파생한다.
deviceId기기별 기록 (SPEC #178) — 종전엔store_playback_statusPK 가store_id라 마지막에 보고한 기기가 앞 기기를 덮어썼고,play_log는 기기 수만큼 부풀면서 어느 기기 것인지 구분되지 않아 신탁 신고 근거가 왜곡됐다.deviceId를 주면 상태가store_device_playback_status(기기당 1행)에, 로그가play_log.device_id에 기기별로 적재된다(조회는 기기 행 우선·없으면 매장 행 병합).play_log멱등키는 partial unique 두 개로 나뉜다 —WHERE device_id IS NULL은 종전(store_id, music_id, started_at)그대로(구버전 보고 보호),WHERE device_id IS NOT NULL은(device_id, music_id, started_at)(두 기기가 같은 곡을 같은 밀리초에 시작해도 서로 다른 재생이라 둘 다 남는다). 타 매장·회수된 기기 id 는 무시하고 매장 단위로 처리한다(보고 실패로 재생을 방해하지 않는다).⚠️ 두 partial unique 는 완전히 disjoint 라 이중 적재 가드가 양방향으로 필요하다. 한쪽(구버전 row 가 먼저 있고 기기 보고가 뒤늦게)은
insertIfAbsentForDevice의NOT EXISTS … device_id IS NULL가드가 막았는데, 반대 방향에도 실제 경로가 있다: 기기 D 가 보고한 뒤 응답이 유실돼 FE 로컬 큐에 남고, 그사이 기기 D 가 회수되면 재전송 시 서버가 무효deviceId를 null 로 접어 매장 단위 경로로 들어가 **row 2건
playback_daily_rollup2배 가산**이 된다(신탁 신고·월 청구 근거라 정확도가 곧 계약 리스크). 이제insertIfAbsentWithoutDevice에 같은(store, music, startedAt)의 기기 row 존재를 보는 대칭 가드가 있고, V65 보조 인덱스idx_play_log_store_music_started_device가 그 조건을 커버한다(없으면 매 재생 보고마다 seq scan —uq_play_log_device_music_started는 선행 컬럼이device_id라 커버 불가). 매장 전체가 아니라 같은 키만 보므로 다른 구버전 PC 의 정상 동시 재생은 삼키지 않는다.
cacheHit캐시 적중 계측 (SPEC #179 D10) — 음원 전송 캐시(Store Player 캐시 계층)의 적중률(원가 모델 M2)을 추정이 아닌 실측으로 얻기 위한 옵션 필드.play_log.cache_hit BOOLEAN NULL(V67)에 그대로 저장되며 롤업 미반영 — 주간 적중률은 로그 원본 SQL 로만 뽑는다. NULL (구버전 미전송)은 모수에서 제외해 계측 도입 이전 로그를 “미적중”으로 오염시키지 않는다. FE 는 곡 시작 시점의 슬롯 확정값(objectURL 재생 여부)을 고정해 보고한다 — 프리페치가 이번에 네트워크로 받아 재생한 곡은false(네트워크 비용이 실제로 발생), 이전 사이클 적재분에서 재생한 곡만true.
BroadcastPreviewRequest / BroadcastPreviewResponse / BroadcastSendRequest / BroadcastSendResponse / BroadcastRecordingResponse (즉시방송 슬라이스 — /api/v1/store/broadcasts*)
점장 즉시방송(2-step 미리듣기 게이트). 입력 방식 2종 — TTS(텍스트→합성)·녹음(마이크→업로드). 어느 방식이든 draft 를 만든 뒤(미리듣기) 자기 매장에 송출한다(send 공유).
// generated `schemas/broadcastPreviewRequest.ts`·`broadcastPreviewResponse.ts`·`broadcastSendRequest.ts`·`broadcastSendResponse.ts`·`broadcastRecordingResponse.ts`·`recordStoreBroadcastParams.ts`
BroadcastPreviewRequest { // POST /api/v1/store/broadcasts/preview body (TTS)
text: string; // 방송 텍스트 (@minLength 1 @maxLength 200)
voice: BroadcastPreviewRequestVoice; // TtsVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA), 톤 NORMAL 고정
isEmergency?: boolean | null; // (SPEC #082) 긴급방송 여부. null/생략=일반 · true=긴급. FE 는 true 만 명시 전송.
}
BroadcastPreviewResponse { // TTS preview 201
announcementId: string; // 생성된 draft 안내방송 id (send 시 사용)
audioUrl: string; // 합성 오디오 미리듣기 URL (Azure public blob)
durationSeconds?: number | null; // 오디오 길이(초), Typecast 제공 시만
isEmergency: boolean; // (SPEC #082) preview 단계에서 확정된 긴급 여부(null=false 정규화). send 단계에서 재확정 가능.
}
// POST /api/v1/store/broadcasts/recording (녹음): multipart file=Blob + query (durationSeconds, isEmergency?)
// RecordStoreBroadcastBody{ file: Blob }
// RecordStoreBroadcastParams{ durationSeconds: number; isEmergency?: boolean } // (SPEC #082) query param — null/생략=false
BroadcastRecordingResponse { // 녹음 업로드 201
announcementId: string; // 생성된 draft 안내방송 id (send 시 사용 — preview 와 동형)
audioUrl: string; // 업로드된 녹음 미리듣기 URL (Azure public blob)
durationSeconds?: number | null; // 녹음 길이(초), 클라 측정값을 서버가 1~30 검증
isEmergency: boolean; // (SPEC #082) multipart query 의 isEmergency 를 반영(null=false). send 단계에서 재확정 가능.
}
BroadcastSendRequest { // POST /api/v1/store/broadcasts/{announcementId}/send body (SPEC #082·#083·#178)
isEmergency?: boolean | null; // (SPEC #082) 송출 마지막 단계의 isEmergency 확정. null/생략 시 preview 단계 값 유지. true=긴급. FE 는 일반이면 생략, 긴급이면 true 명시 전송.
scheduledAt?: string | null; // (SPEC #083) ISO-8601 nullable. null/생략=즉시 송출(기존 동작·status=PENDING). non-null=예약 송출(status=SCHEDULED, scheduled_at 적재 → 본사 #078 `HqDispatchScheduler` 1분 cron 이 도래 시 PENDING 자동 전이 → 점장 player polling 자동 픽업, 코드 변경 0). 과거(`<= now`) → 400 BROADCAST_SCHEDULED_AT_PAST · 1년 초과 → 400 BROADCAST_SCHEDULED_AT_TOO_FAR. isEmergency 와 자유 조합 가능(긴급 예약 = 정당한 use case). FE 는 일반 즉시 송출이면 `{}` 빈 body, 예약이면 `{ scheduledAt }`, 긴급 예약이면 `{ isEmergency: true, scheduledAt }`.
deviceId?: string | null; // (SPEC #178) 이 방송을 내보내는 기기 id(`POST /store/devices` 응답의 id). **즉시 송출에만 유효** — 본인 매장의 살아있는 기기면 announcement_dispatch.target_device_id(V63)에 박혀 **그 기기 1대만** pending 으로 받는다("점장 즉시방송 = 누른 그 PC 에서만"). 생략하면 매장 전 기기 대상이라 다른 PC 들도 폴링으로 같은 방송을 받아 최대 20초씩 어긋나 반복 재생된다(구버전 하위호환은 그대로). **예약 송출(scheduledAt non-null)에는 무시**(예약은 정의상 전 기기 동시 재생). 타 매장·회수된 기기 id 는 400 이 아니라 전 기기 폴백. FE 는 `!isScheduled && deviceId` 일 때만 싣는다.
}
BroadcastSendResponse { // POST /api/v1/store/broadcasts/{announcementId}/send 201 (공유)
dispatchId: string; // 생성된 송출 id
}operationId
previewStoreBroadcast(201)·sendStoreBroadcast(201). preview 는 STORE_BROADCAST draft row 를 만들 뿐 dispatch 하지 않는다(미리듣기 게이트). send 는 그 draft 를 본인 매장에 PENDING 송출로 fan-out 한다 — 이후 점장 player 의 pending 폴링(listStorePendingAnnouncements)이 잡아 음악 위에 동시재생(더킹 + 오버레이 · SPEC #141)→ack(본사 송출과 동일 고리 재사용).storeId·hqId는 토큰 주체에서 도출. 미설정 503TTS_TOKEN_NOT_CONFIGURED·합성 실패 502TTS_SYNTHESIS_FAILED(둘 다 row 미생성)· send 시 만료/타 매장 draft 404. voice enum 은 endpoint 별 generated typeBroadcastPreviewRequestVoice(value 는TtsVoice5종과 동일). 녹음 탭은recordStoreBroadcast(multipartfile+durationSeconds)로 같은 draft 를 만들어 동일 send 로 송출한다 — 미지원 형식 400RECORDING_UNSUPPORTED_FORMAT·빈/10MB 초과/길이 범위 밖 400RECORDING_INVALID_FIELD.apps/space/store/broadcast/nowTTS·녹음 탭이 미리듣기 게이트를 공유(송출 버튼은 미리듣기/업로드 성공 후 활성, 입력 변경·탭 전환 시 재비활성).
BroadcastTemplateResponse / BroadcastTemplateListResponse / CreateBroadcastTemplateRequest / UpdateBroadcastTemplateRequest (자주쓰는방송 슬라이스 — /api/v1/store/broadcast-templates*)
점장 자주 쓰는 방송 템플릿 CRUD. 저장해둔 안내(이름·텍스트·목소리)를 TTS 탭으로 불러와(로드) 재사용한다. 합성/송출은 하지 않고 즉시방송 입력을 채우는 로드까지만 담당한다(미리듣기·송출은 TTS 게이트가 맡음).
// generated `schemas/broadcastTemplateResponse.ts`·`broadcastTemplateListResponse.ts`·`createBroadcastTemplateRequest.ts`·`updateBroadcastTemplateRequest.ts`
BroadcastTemplateResponse { // 본인 매장 활성 템플릿 행 (updated_at DESC)
id: string; // 템플릿 id
name: string; // 표시 이름
text: string; // 방송할 텍스트
voice: BroadcastTemplateResponseVoice; // TtsVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA)
createdAt: string; // 생성 시각 (ISO-8601)
updatedAt: string; // 수정 시각 (ISO-8601)
}
BroadcastTemplateListResponse { // GET /api/v1/store/broadcast-templates 200
items: BroadcastTemplateResponse[]; // updated_at DESC
total: number; // 활성 템플릿 건수 (매장당 최대 20)
}
CreateBroadcastTemplateRequest { // POST body (201)
name: string; // @minLength 1 @maxLength 60
text: string; // @minLength 1 @maxLength 200 (즉시방송 text 와 동일 제약)
voice: CreateBroadcastTemplateRequestVoice;
}
UpdateBroadcastTemplateRequest { // PUT /{id} body (200) — Create 와 동형
name: string; // @minLength 1 @maxLength 60
text: string; // @minLength 1 @maxLength 200
voice: UpdateBroadcastTemplateRequestVoice;
}operationId
listBroadcastTemplates(200)·createBroadcastTemplate(201)·updateBroadcastTemplate(200)·deleteBroadcastTemplate(204, 소프트삭제). 목록은 페이지네이션 없이 전량(매장당 최대 20,updated_at DESC). 생성 시 20개 도달 409BROADCAST_TEMPLATE_LIMIT_EXCEEDED, 수정/삭제 시 본인 매장 활성 템플릿이 아니면 404BROADCAST_TEMPLATE_NOT_FOUND(소유 은닉).storeId는 토큰 주체에서 도출. voice enum 은 endpoint 별 generated type(value 는TtsVoice5종과 동일 —broadcast-voice-meta.ts라벨 재사용).apps/space/store/broadcast/now자주 쓰는 방송 탭(목록·인라인 저장/편집·2-step 삭제·행 탭=로드).
StoreScheduledBroadcastItem / StoreScheduledBroadcastListResponse (SPEC #091 · #102 페이지네이션 — /api/v1/store/scheduled-broadcasts)
점장 본인 매장의 예약 송출(SCHEDULED dispatch row) 목록 조회 응답. 본사 #078 디스패처가 도래 시 PENDING
으로 전이하기 직전 row 만 노출되며(즉시/PENDING·종착/PLAYED·CANCELED 자동 제외), 정렬은 scheduled_at ASC
(가장 가까운 예약이 위). storeId 는 토큰 주체에서 도출 → 타 매장 row 비노출. #102 마감: query
page?=0·size?=20(size 1..50 clamp) + envelope {items,page,size,total} — total 은 페이지 무관 본인 매장
SCHEDULED 전체 카운트.
// generated `schemas/storeScheduledBroadcastItem.ts` · `storeScheduledBroadcastListResponse.ts`
StoreScheduledBroadcastItem { // GET /api/v1/store/scheduled-broadcasts 200 item
dispatchId: string; // SCHEDULED dispatch row id (UUID)
announcementTitle: string; // announcement 와 JOIN — 표 첫 컬럼
audioUrl: string; // announcement.audio_url 스냅샷 (Azure public blob, 미리듣기 후속)
scheduledAt: string; // ISO-8601 UTC — 도래 시 디스패처가 PENDING 전이
isEmergency: boolean; // (SPEC #082) 긴급 예약 여부 — 표에 "긴급" pill 분기
createdAt: string; // ISO-8601 UTC — 예약 row 등록 시각
}
StoreScheduledBroadcastListResponse { // GET 200
items: StoreScheduledBroadcastItem[]; // scheduled_at ASC, 현재 페이지 size 만큼
page: number; // (#102) 0-base 현재 페이지
size: number; // (#102) 페이지 크기 (1..50 clamp, default 20)
total: number; // (#102) 페이지 무관 본인 매장 SCHEDULED 전체 카운트
}operationId
listStoreScheduledBroadcasts(200). querypage?·size?. STORE_MANAGER-only · 미인증 401 · 비활성/role 불일치 403PRINCIPAL_SCOPE_MISMATCH. generated 훅useListStoreScheduledBroadcasts({ page, size })·getListStoreScheduledBroadcastsQueryKey.apps/space/store/broadcast/scheduledread-only 표 + 하단ListPagination(total > size일 때만 노출, 1-base display). 미리듣기 audio 노출은 후속 F2.
SlotOccupancyResponse / GetStoreSlotOccupancyParams (SPEC #109 — /api/v1/store/slot-occupancy)
점장 예약 그리드가 송출 전에 본사 점유 슬롯을 비활성 표시하기 위한 사전 조회 응답. 요청 date 는 KST
하루로 해석되고(자정~다음날 자정 [from, to)), 응답 occupiedSlots 는 그 창에서 본사(is_hq_origin=true)가
예약(SCHEDULED) 한 슬롯 시작 시각을 5분 경계로 floor 한 UTC ISO-8601 배열이다(slot_start ASC).
점장 본인 예약은 포함하지 않는다 — 점장끼리의 동일 시각 중복은 허용(SPEC #109 D4)이라 본인 예약 표시는
listStoreScheduledBroadcasts 가 담당한다.
// generated `schemas/getStoreSlotOccupancyParams.ts` · `slotOccupancyResponse.ts`
GetStoreSlotOccupancyParams { // GET /api/v1/store/slot-occupancy query
date: string; // 필수 — 조회 대상 날짜 (KST, YYYY-MM-DD)
}
SlotOccupancyResponse { // GET 200
date: string; // 요청 echo (KST YYYY-MM-DD)
occupiedSlots: string[]; // 본사 점유 5분 슬롯 시작 시각 (UTC ISO-8601, slot_start ASC). 없으면 []
}operationId
getStoreSlotOccupancy(200). STORE_MANAGER-only · 미인증 401 · 비활성/role 불일치 403PRINCIPAL_SCOPE_MISMATCH. generated 훅useGetStoreSlotOccupancy({ date })·getGetStoreSlotOccupancyQueryKey.apps/space즉시방송 모달 §예약 송출의 5분 슬롯 그리드가scheduleMode === "SCHEDULED"일 때 선택 날짜로 호출하고, UTC ISO 를 KSTHH:mm으로 변환해 셀 키에 매핑한다(날짜를 바꾸면 queryKey 가 바뀌어 자동 refetch). 조회 이후 본사가 새로 예약한 race 는 send 409DISPATCH_SLOT_OCCUPIED로만 알 수 있어, FE 가 사전 조회 ∪ 409 사후 보정으로 합쳐 표시한다(감사 #9).
Operator DTOs (#032 — /api/v1/admin/operators*)
운영자(OPERATOR) 계정 관리. 점장(#021·#029)과 동일 AccountStatus 라이프사이클이되 self-guard·마지막 활성 운영자 보호가 추가된다.
OperatorListResponse · OperatorListItem · ListOperatorsParams (#032 · #043)
// generated `schemas/operatorListResponse.ts` · `operatorListItem.ts` · `listOperatorsParams.ts`
// #043 — 검색·상태필터·페이지네이션 query 파라미터(전부 optional).
ListOperatorsParams {
q?: string; // email·name 부분일치(대소문자 무시), max 100
status?: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // ListOperatorsStatus — 미지정 시 전체(WITHDRAWN 포함)
page?: number; // 0-base, default 0
size?: number; // default 20, 1..100 clamp
}
// #043 — envelope 전환(`{items}` → `{items,page,size,total}`).
OperatorListResponse {
items: OperatorListItem[]; // 현재 페이지(createdAt asc, id asc)
page: number; // 0-base 페이지 번호
size: number; // 페이지 크기(1..100)
total: number; // 필터 적용 후 전체 건수
}
OperatorListItem {
id: string;
email: string;
name?: string | null; // 없으면 null → FE '—'
status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // OperatorListItemStatus
passwordMustChange: boolean; // 임시 비번 상태(첫 로그인 변경 강제)
lastLoginAt?: string | null; // ISO, 없으면 null
createdAt: string; // ISO
isSelf: boolean; // 현재 로그인 운영자 본인 여부(FE 자기 행 액션 비활성)
}OperatorIssueRequest · OperatorIssueResponse (#032)
OperatorIssueRequest { email: string; name: string; tempPassword: string; } // tempPassword OpenAPI minLength 8/maxLength 100, email·name max 255 (런타임 @Email·@NotBlank)
OperatorIssueResponse { id: string; } // 201, passwordMustChange=trueOperatorResetPasswordRequest · OperatorSuspendRequest · OperatorAccountResponse (#032)
OperatorResetPasswordRequest { tempPassword: string; } // OpenAPI minLength 8, maxLength 100
OperatorSuspendRequest { reason: string; } // OpenAPI maxLength 255 (런타임 @NotBlank)
OperatorAccountResponse {
id: string;
status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN"; // 전이 후 상태
passwordMustChange: boolean; // 비번 재설정 시 true
}reset-password응답 =OperatorAccountResponse. ACTIVE 계정만(그 외 → 409ACCOUNT_INVALID_STATUS_TRANSITION). 본인 → 403OPERATOR_SELF_ACTION_FORBIDDEN. FE zod 가 길이(min 8/max 100)를 미러해 사전 reject.suspend본문 =OperatorSuspendRequest. 본인 → 403OPERATOR_SELF_ACTION_FORBIDDEN· 마지막 활성 → 403OPERATOR_LAST_ACTIVE_FORBIDDEN. FE zod 가 trim 후 min 1 로 NotBlank 미러.reactivate(body 없음)·revoke(DELETE, body 없음)는 상태 전이만. 회수는 본인·마지막 활성 보호 동일. 잘못된 전이/이미 WITHDRAWN → 409 · 미존재 → 404OPERATOR_NOT_FOUND.- 정지·재설정·회수는 대상 운영자의 활성 세션을 즉시 무효화한다.
Admin / Stats DTOs
AdminStatsResponse (#017 — GET /api/v1/admin/stats, OPERATOR-only · #035·#126 확장)
ops 대시보드 핵심 지표. 전부 현재 데이터로 계산 가능한 값만(매출·장애 제외 — 도메인 미존재/무료 MVP).
#035 분포·추이 필드, #126 송출·audit 필드 추가(operationId·경로·인가 불변). 기존
필드(totalHqs·hqsByStatus·newHqs7d·totalStores·activeImpersonations)는 불변 유지(FE
기존 카드 회귀 없음).
{
totalHqs: number; // Hq count
hqsByStatus: HqStatusCounts; // group by status
newHqs7d: number; // Hq createdAt ≥ now−7d
totalStores: number; // Store count
activeImpersonations: number; // active(미revoke·미만료) impersonated RefreshToken 수
// — #035 추가 —
storesByStatus: StoreStatusCounts; // Store.status group by (합 = totalStores)
hqsByType: HqTypeCounts; // Hq.type group by
accounts: AccountStatsCounts; // OperatorAccount role×status 단일 group by(최소셋)
tickets: TicketStatsCounts; // Ticket.status group by + 미배정·긴급 OPEN 보조 count
hqSignups30d: DailyCount[]; // 최근 30일 본사 가입 추이(가입 있는 날만 — FE 가 0 채움)
// — #126 추가 —
dispatch: DispatchStatsCounts; // AnnouncementDispatch.status group by + 최근 7일 송출 건수
dispatchTrend30d: DailyCount[]; // 최근 30일 송출 추이(송출 있는 날만 — FE 가 0 채움)
storeAuditActivity7d: number; // 최근 7일 매장 audit(store_audit_log) 활동 수
}HqStatusCounts (#017)
{
active: number;
onboarding: number;
unpaid: number;
suspended: number;
}StoreStatusCounts (#035)
{
active: number; // ACTIVE 매장 수
suspended: number; // SUSPENDED 매장 수
inactive: number; // INACTIVE 매장 수
}HqTypeCounts (#035)
{
franchise: number; // FRANCHISE 본사 수
independent: number; // INDEPENDENT(가상 본사) 수
}AccountStatsCounts (#035 — 최소셋)
운영자·점장·HQ매니저 계정 분포(최소셋). 점장·HQ매니저의 SUSPENDED/WITHDRAWN 은 비목표.
{
operatorsActive: number; // ACTIVE 운영자 수
operatorsSuspended: number; // SUSPENDED 운영자 수
hqManagersActive: number; // ACTIVE HQ매니저 수
storeManagersActive: number; // ACTIVE 점장 수
}TicketStatsCounts (#035)
CS 티켓 분포. 미해결 = open + inProgress(FE 합산). unassignedOpen·urgentOpen 은 OPEN
한정 보조 count.
{
open: number;
inProgress: number;
resolved: number;
closed: number;
unassignedOpen: number; // 미배정(assignee NULL) OPEN 수
urgentOpen: number; // 긴급(URGENT) OPEN 수
}DispatchStatsCounts (#126)
안내방송 송출(AnnouncementDispatch) status 분포 + 최근 7일 송출 건수. 도달률은 BE 가 보내지
않고 FE 파생(played / (pending + played)). dispatch row 수 = 본사→매장 fan-out 시도 총량.
{
pending: number; // PENDING(미재생) 송출 수
played: number; // PLAYED(재생 완료) 송출 수
scheduled: number; // SCHEDULED(예약 대기) 송출 수
canceled: number; // CANCELED(본사 취소) 송출 수
last7dCount: number; // 최근 7일 송출 건수(전역 dispatch row 수)
}DailyCount (#035 — hqSignups30d·#126 dispatchTrend30d 공용)
{
date: string; // 일자 ISO date (YYYY-MM-DD)
count: number; // 해당 일자 건수
}
hqSignups30d·dispatchTrend30d는 BE 가 있는 날만 반환한다(가입 추이는 가상 본사 INDEPENDENT 제외). FE 는 오늘(KST) 기준 최근 30일 축을 만들어 빈 날count=0으로 채워 렌더한다(매칭은 ISO date 문자열 비교, KST 기준일 —recentKstDayKeys유틸).도달률·티켓 해결률은 BE 필드가 아니라 FE 파생이다(
AdminStatsResponse에 비율 필드 없음): 도달률 =dispatch.played / (dispatch.pending + dispatch.played), 티켓 해결률 =(tickets.resolved + tickets.closed) / Σtickets. 분모 0 이면 ”—”.
TtsSmokeTestResponse (#134 — POST /api/v1/admin/tts/smoke-test, OPERATOR-only)
TTS 연동 진단(부작용 없는 합성 1회) 성공 응답. 실패는 표준 ErrorResponse(503 TTS_TOKEN_NOT_CONFIGURED·
502 TTS_SYNTHESIS_FAILED).
TtsSmokeTestResponse {
ok: boolean; // 항상 true (성공 응답에서만)
voice: string; // 진단에 쓴 voice enum name (SHEAN)
durationSeconds?: number | null; // 합성 오디오 길이(초). 미제공 시 null
audioByteSize: number; // 합성 오디오 bytes 크기
elapsedMs: number; // smoke-test 호출 소요(ms)
}Audit DTOs
ImpersonationAuditItem (#025 — GET /api/v1/admin/audit/impersonation item)
임퍼소네이션 감사 세션 한 건. 운영자(OPERATOR)가 본사 계정으로 진입한 세션 메타.
{
id: string; // 세션 식별자
operatorEmail: string; // 진입한 운영자 email (운영자 표시명은 BE v1 미제공)
hqName: string; // 진입 대상 본사명 (진입 시점 스냅샷)
startedAt: string; // ISO — exchange(실제 진입) 시각
endedAt?: string; // ISO — 명시적 종료(EXITED) 시각. 미종료/만료면 omit
durationSec: number; // 파생 — 종료 시각(없으면 now) − startedAt (초)
status: ImpersonationAuditItemStatus; // 파생 — 아래 enum
}status파생 규칙(BE):endedAt있으면COMPLETED· 없고now > expiresAt면EXPIRED· 그 외ACTIVE.- BE v1 미제공(시안엔 있으나 데이터 없음): 운영자 표시명(email 만 제공)·ticket·진입 사유(reason)·세션 내 액션 타임라인(actions). 액션 타임라인은 HQ 모드(Surface 11)가 이벤트를 emit해야 채워지는 후속.
ImpersonationAuditItemStatus (#025)
"ACTIVE" | "COMPLETED" | "EXPIRED"ImpersonationAuditListResponse (#025 — 페이지네이션 응답)
{
items: ImpersonationAuditItem[];
page: number; // 0-based
size: number;
total: number;
}ListImpersonationAuditParams (#025 — query · generated ListImpersonationAuditStatus, operationId listImpersonationAudit)
{
from?: string; // ISO — startedAt ≥ from
to?: string; // ISO — startedAt ≤ to
operatorId?: string;
hqId?: string;
status?: "ACTIVE" | "COMPLETED" | "EXPIRED";
page?: number; // 0-based
size?: number;
}OperatorAuditItem (#026 — GET /api/v1/admin/audit/actions item)
운영자(OPERATOR)의 핵심 변경 액션 한 건. 임퍼소네이션 세션 감사(#025)와는 별도 테이블 (append-only 공통 감사 로그)이며 “누가·언제·무엇을·어느 대상에” 를 추적한다.
{
id: string; // 감사 row 식별자
occurredAt: string; // ISO — 액션 발생 시각
actorEmail: string; // 액션을 수행한 운영자 email (스냅샷)
action: OperatorAuditItemAction; // 아래 enum
targetType: OperatorAuditItemTargetType; // 아래 enum
targetId?: string; // 대상 식별자(UUID). 일부 액션은 omit
targetLabel?: string; // 대상 표시 스냅샷(hqName·storeName·약관 version 등)
detail?: string; // 부가 상세(HQ_SUSPENDED 정지 사유·게시 version 등)
}OperatorAuditItemAction (#026 · 운영자 액션 5종 #032 추가)
"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_ONBOARDED"| enum | 한글 라벨 | 계기 (BE service) |
|---|---|---|
HQ_SUSPENDED | 본사 정지 | HqStatusService.suspend (#018/#024) |
HQ_REACTIVATED | 본사 복구 | HqStatusService.reactivate |
STORE_MANAGER_ISSUED | 점장 발급 | StoreManagerService.issue (#021) |
STORE_MANAGER_PASSWORD_RESET | 점장 비밀번호 재설정 | StoreManagerService.resetPassword (#029) |
STORE_MANAGER_SUSPENDED | 점장 정지 | StoreManagerService.suspend (#029) |
STORE_MANAGER_REACTIVATED | 점장 복구 | StoreManagerService.reactivate (#029) |
STORE_MANAGER_REVOKED | 점장 회수 | StoreManagerService.revoke (#029) |
OPERATOR_ISSUED | 운영자 초대 | OperatorService.issue (#032) |
OPERATOR_PASSWORD_RESET | 운영자 비밀번호 재설정 | OperatorService.resetPassword (#032) |
OPERATOR_SUSPENDED | 운영자 정지 | OperatorService.suspend (#032) |
OPERATOR_REACTIVATED | 운영자 복구 | OperatorService.reactivate (#032) |
OPERATOR_REVOKED | 운영자 회수 | OperatorService.revoke (#032) |
TERMS_PUBLISHED | 약관 게시 | TermsDocumentService.publish (#023) |
PRIVACY_POLICY_PUBLISHED | 개인정보처리방침 게시 | PrivacyPolicyService.publish (#023) |
HQ_ONBOARDED | 본사 온보딩 | HqOnboardingService.register (#011) |
OperatorAuditItemTargetType (#026 · OPERATOR #032 추가)
"HQ" | "STORE" | "OPERATOR" | "TERMS" | "PRIVACY_POLICY"한글 라벨: 본사 · 매장 · 운영자 · 약관 · 개인정보처리방침.
OperatorAuditListResponse (#026 — 페이지네이션 응답)
{
items: OperatorAuditItem[];
page: number; // 0-based
size: number;
total: number;
}ListOperatorActionAuditParams (#026 — query · generated ListOperatorActionAuditAction/ListOperatorActionAuditTargetType, operationId listOperatorActionAudit)
{
from?: string; // ISO — occurredAt ≥ from
to?: string; // ISO — occurredAt ≤ to
action?: OperatorAuditItemAction;
actorOperatorId?: string;
targetType?: OperatorAuditItemTargetType;
q?: string; // #034 — targetLabel+detail 부분일치(ILIKE, 대소문자 무시), max 100
page?: number; // 0-based
size?: number;
}OperatorHqAuditItem (#081 — GET /api/v1/admin/audit/hq item · #068 F4 마감)
본사(HQ_MANAGER 또는 임퍼소네이션 운영자)가 수행한 audit 행을 운영사 통합 view 로 노출한 한 건.
본사 view HqAuditItem(#067) 미러 + hqName 컬럼 추가(어느 본사의 audit 인지 식별). 본사 view
와의 차이: WHERE hq_id 가드 없음(OPERATOR-only — 전체 본사 합집합). append-only.
{
id: string; // audit row 식별자
hqId: string; // 본사 id (audit 행이 속한 본사)
hqName: string; // 본사명 (hq JOIN 결과 — 현재값 스냅샷)
occurredAt: string; // ISO — 액션 발생 시각
actorEmail: string; // 본사 매니저 이메일 (스냅샷)
actorRole: OperatorHqAuditItemActorRole; // "HQ_MANAGER" | "OPERATOR_IMPERSONATING"
impersonatedByEmail?: string | null; // 위장 컨텍스트에서만, 그 외 null
action: OperatorHqAuditItemAction; // 아래 enum (5종)
targetType: OperatorHqAuditItemTargetType; // 아래 enum
targetId?: string | null; // 대상 식별자(없으면 null)
targetLabel?: string | null; // 대상 라벨 스냅샷(없으면 null)
detail?: string | null; // 액션 상세(없으면 null)
}OperatorHqAuditItemAction (#081 — 본사 audit 5종, HqAuditItemAction #067/#068/#077 미러)
"HQ_ANNOUNCEMENT_DISPATCHED" | "HQ_ANNOUNCEMENT_CREATED"
| "HQ_ANNOUNCEMENT_UPDATED" | "HQ_ANNOUNCEMENT_DELETED"
| "HQ_ANNOUNCEMENT_DISPATCH_CANCELED"한글 라벨: 송출 · 생성 · 수정 · 삭제 · 송출 취소 (운영사 측 매핑 — 본사 매핑과 동일).
OperatorHqAuditItemTargetType (#081)
"TTS_ANNOUNCEMENT"한글 라벨: 안내방송. 1종(SPEC #067), 후속 확장 시 generated enum 자동 노출.
OperatorHqAuditItemActorRole (#081 — HqAuditItemActorRole 미러)
"HQ_MANAGER" | "OPERATOR_IMPERSONATING"한글 라벨: 본사 매니저 · 운영자 위장.
OperatorHqAuditListResponse (#081 — 페이지네이션 응답)
{
items: OperatorHqAuditItem[];
page: number; // 0-based
size: number;
total: number;
}ListOperatorHqAuditParams (#081 — query · generated ListOperatorHqAuditAction/ListOperatorHqAuditTargetType, operationId listOperatorHqAudit)
{
from?: string; // ISO — occurredAt ≥ from
to?: string; // ISO — occurredAt ≤ to
hqId?: string; // 본사 id 필터(null=전체 본사 통합)
actorAccountId?: string;
action?: OperatorHqAuditItemAction;
targetType?: OperatorHqAuditItemTargetType;
q?: string; // targetLabel+detail 부분일치(ILIKE), max 100
page?: number; // 0-based
size?: number;
}StoreAuditListItem (#115 — GET /api/v1/admin/audit/store item · #114 D5 reader 마감)
운영자 통합 매장 audit 행(모든 매장 합집합). hq audit OperatorHqAuditItem(#081) 1:1 미러 — 테넌트
스코프 키만 hqId/hqName → storeId/storeName, 액션·대상 enum 이 점장 도메인용으로 바뀐다.
{
id: string;
storeId: string; // store JOIN 키(audit 행이 속한 매장)
storeName: string; // 매장명(현재값, store JOIN 결과)
occurredAt: string; // ISO-8601
actorEmail: string; // 행위자 점장 이메일(스냅샷)
actorRole: StoreAuditListItemActorRole; // "STORE_MANAGER" | "OPERATOR_IMPERSONATING"
impersonatedByEmail?: string | null; // 위장 컨텍스트에서만(그 외 null)
action: StoreAuditListItemAction; // 아래 enum (11종)
targetType: StoreAuditListItemTargetType; // 아래 enum
targetId?: string | null;
targetLabel?: string | null;
detail?: string | null;
}StoreAuditListItemAction (#115 — 점장 액션 11종, StoreAuditAction #114 미러)
"STORE_TICKET_CREATED" // 티켓 생성
"STORE_TICKET_COMMENT_ADDED" // 티켓 댓글
"STORE_TICKET_CLOSED" // 티켓 확인 종료 (#121)
"STORE_PROFILE_UPDATED" // 프로필 수정
"STORE_PASSWORD_CHANGED" // 비밀번호 변경
"STORE_DISPATCH_CANCELED" // 송출 취소
"STORE_DISPATCH_BROADCAST_NOW" // 즉시 방송 (#142 — 예약을 앞당겨 1회 추가 송출)
"STORE_DEVICE_RENAMED" // 기기 이름 변경 (#178)
"STORE_DEVICE_REVOKED" // 기기 정리(회수) (#178)
"STORE_DEVICE_PLAYLIST_CHANGED" // 기기별 재생목록 변경 (#178)
"STORE_ACTIVE_PLAYLIST_CHANGED" // 활성 PL 변경 (#129 — 선택·해제)FE 톤: 티켓 생성/댓글·프로필 수정·활성 PL 변경·즉시 방송·기기 이름 변경·기기 PL 변경 = info(정상 활동), 티켓 종료 = success, 비밀번호 변경·송출 취소·기기 정리 = warn(보안 민감·부정 종착·되돌릴 수 없음). 색만으로 구분하지 않고 한글 라벨 병기(
기기 이름 변경·기기 정리·기기 PL 변경). 기기 등록은 audit 하지 않는다 — player 마운트마다 일어나는 멱등 동작이라 로그가 그것만으로 찬다.
StoreAuditListItemTargetType (#115 · #129 · #178)
"DISPATCH" // 송출
"PLAYLIST" // 플레이리스트 (#129 — 활성 PL 변경 대상)
"STORE_DEVICE" // 매장 기기 (#178 — 이름 변경·회수·기기 PL 변경 대상, target_id = store_device.id)
"STORE_MANAGER" // 점장 계정
"SUPPORT_TICKET" // CS 티켓StoreAuditListItemActorRole (#115 — StoreAuditActorRole 미러)
"STORE_MANAGER" // 점장 본인 직접 액션
"OPERATOR_IMPERSONATING" // 운영자가 점장 모드로 위장StoreAuditListResponse (#115 — 페이지네이션 응답)
{
items: StoreAuditListItem[];
page: number; // 0-based
size: number;
total: number;
}ListStoreAuditParams (#115 — query · generated ListStoreAuditAction, operationId listStoreAudit)
ExportStoreAuditParams(operationId exportStoreAudit)는 동일하되 page/size 만 제외.
{
from?: string; // ISO — occurredAt ≥ from
to?: string; // ISO — occurredAt ≤ to
storeId?: string; // 매장 id 필터(null=전체 매장 통합)
actorId?: string; // 행위자(점장/임퍼소네이션 운영자) 계정 id
action?: ListStoreAuditAction;
q?: string; // targetLabel+detail 부분일치(ILIKE), max 100
page?: number; // 0-based
size?: number;
}Ticket DTOs (#027 — CS 티켓 v1, #113 — store 식별)
#113: 운영자 목록·상세에
storeName·category(nullable) 가 추가되고, 목록 query 에category·storeId필터가 추가됐다(read-sideLEFT JOIN store+ DTO 필드 — 마이그레이션 0). category enum (TicketListItemCategory·TicketDetailCategory·ListTicketsCategory)은 점장StoreTicketListItemCategory와 값 동일(PLAYBACK·BROADCAST·BILLING·ACCOUNT·OTHER) — generated 만 응답별로 분리 emit. FE 는 점장 support 와 동일 한글 라벨(apps/adminticket-meta.tsCATEGORY_META)로 표기한다.
TicketListItem (GET /api/v1/admin/tickets item)
{
id: string;
title: string;
status: TicketStatus; // OPEN | IN_PROGRESS | RESOLVED | CLOSED
priority: TicketPriority; // URGENT | HIGH | NORMAL | LOW
category?: TicketListItemCategory; // #113 nullable — PLAYBACK|BROADCAST|BILLING|ACCOUNT|OTHER. legacy/hq 티켓은 null → FE "—"
hqName?: string | null; // #028 nullable — 본사 미연계 시 null. FE "—"/미연계 표시
storeName?: string | null; // #113 nullable — store ticket 만 값(LEFT JOIN store). FE 출처(매장명) 표기
assigneeEmail?: string | null; // #028 nullable — 미배정 시 null. FE "미배정" 표시
commentCount: number;
createdAt: string; // ISO
updatedAt: string; // ISO — 목록 정렬 기준(desc)
}TicketListResponse (페이지네이션 응답)
{
items: TicketListItem[];
page: number; // 0-based
size: number;
total: number;
}ListTicketsParams (query — generated ListTicketsStatus/ListTicketsPriority/ListTicketsCategory, operationId listTickets)
{
status?: TicketStatus;
priority?: TicketPriority;
category?: ListTicketsCategory; // #113 — PLAYBACK|BROADCAST|BILLING|ACCOUNT|OTHER (분류별 트리아지)
assigneeOperatorId?: string;
hqId?: string;
storeId?: string; // #113 — UUID, 점장 store ticket 트리아지(FE 는 보조: URL 진입만)
page?: number; // 0-based
size?: number;
}TicketDetail (GET /api/v1/admin/tickets/{id})
{
id: string;
title: string;
body: string;
status: TicketStatus;
priority: TicketPriority;
category?: TicketDetailCategory; // #113 nullable — PLAYBACK|BROADCAST|BILLING|ACCOUNT|OTHER. FE 헤더 분류 배지(null 이면 생략)
hqId?: string | null; // #028 nullable
hqName?: string | null; // #028 nullable
storeId?: string | null; // #028 nullable
storeName?: string | null; // #113 nullable — store ticket 만 값. FE 헤더 출처(매장: {storeName})
assigneeOperatorId?: string | null; // #028 nullable — 미배정 시 null
assigneeEmail?: string | null; // #028 nullable
createdByOperatorId: string;
createdAt: string;
updatedAt: string;
comments: TicketCommentItem[]; // 채팅형 스레드
attachments: TicketAttachmentAdminItem[]; // 운영자 전용 item — INTERNAL 댓글 첨부 포함 + visibility 로 구분
}TicketAttachmentItem / TicketAttachmentAdminItem / TicketAttachmentUploadResponse (BE PR #324·#325)
// generated `schemas/ticketAttachmentItem.ts` — 비운영자 3 채널(본사·하위 매장·점장)
TicketAttachmentItem {
attId: string; // 첨부 id
commentId?: string | null; // 귀속 댓글 id(같은 응답 comments[] 안) · 티켓 본문 첨부면 null
fileName: string; // 원본 파일명
contentType: string; // image/png | image/jpeg | image/gif | image/webp | application/pdf
sizeBytes: number; // 파일 크기(바이트) — FE 는 "1.4 MB" 등 사람이 읽는 단위로 표기
createdAt: string; // ISO-8601(FE 는 KST 표기)
downloadUrl: string; // 채널별 다운로드 프록시 경로 — **public blob URL 이 아니라 인증 필요 경로**
}
// generated `schemas/ticketAttachmentAdminItem.ts` — **운영자 채널 전용**(= 위 필드 + visibility)
TicketAttachmentAdminItem {
...TicketAttachmentItem 의 모든 필드;
visibility: "SHARED" | "INTERNAL"; // 서버 판정(본문·REPLY 댓글=SHARED / INTERNAL 댓글=INTERNAL)
}
TicketAttachmentUploadResponse { attachment: TicketAttachmentAdminItem } // 운영자 업로드 2014 채널 상세 응답이 모두 attachments(없으면 [], 생성순 오름차순)를 갖는다 — 운영자
(TicketDetail)는 TicketAttachmentAdminItem[], 비운영자(HqTicketDetailResponse·
HqStoreTicketDetailResponse·StoreTicketDetailResponse)는 TicketAttachmentItem[].
visibility 가 비운영자 채널에 없는 것은 의도다 — 값이 항상 SHARED 일 뿐 아니라, 필드의 존재
자체가 “INTERNAL 이라는 상태가 있다”를 알려 존재 은닉을 깬다. FE 공유 컴포넌트는 이 필드를 옵셔널로
받아 있을 때만(=운영자 화면) 항목 배지를 그린다 — undefined 를 SHARED 로 취급하지 않는다.
commentId 는 “어느 댓글에 붙었나”(표시 위치)이고 visibility 는 “누가 볼 수 있나”라 역할이 다르다.
업로드 응답에는 댓글 스레드가 함께 오지 않으므로 commentId 만으로는 INTERNAL 여부를 되짚을 수 없다.
downloadUrl 은 상대 경로라 FE 가 BFF prefix(/api/backend)를 붙여 같은 출처 <a download> 로
받는다(쿠키 자동 · 네비게이션 없음 — 점장 player 모달에서 재생 무중단). 공유 FE 본체는
@linkmusic/ui TicketAttachments(+ mapTicketAttachmentError·validateTicketAttachmentFile·
ticketAttachmentHref) 하나이며 4 채널이 업로드 채널·downloadUrl 만 주입한다. 업로드 전송은
@linkmusic/api-client useUploadTicketAttachmentFile(channel) 하나로 통일되고 4MB 초과면 업로드
티켓(백엔드 직접), 이하면 채널 multipart 로 경로가 갈린다(201 응답 계약이 같아 파싱 분기 없음).
TicketCommentItem
{
id: string;
authorOperatorId: string;
kind: CommentKind; // REPLY(고객 응답) | INTERNAL(내부 메모)
body: string;
createdAt: string;
}TicketCreateRequest / TicketCreateResponse
// 요청 — category? 옵셔널(#167). 미지정=미분류(운영자 내부 티켓 허용). 점장 create 는 category 필수.
{ title: string; body: string; priority: TicketPriority; hqId?: string; storeId?: string; category?: TicketCategory | null }
// 응답
{ id: string }요청 enum TicketCreateRequestCategory(값은 TicketCategory 와 동일 · nullable). FE 생성 다이얼로그는
“미분류”(빈 값) 기본 + 5종 select — 미선택 시 category 를 payload 에서 생략한다.
TicketCommentCreateRequest / TicketCommentCreateResponse
// 요청
{ kind: CommentKind; body: string }
// 응답
{ id: string }TicketStatusChangeRequest / TicketStatusChangeResponse
// 요청 — 잘못된 전이는 409 TICKET_INVALID_STATUS_TRANSITION
{ status: TicketStatus }
// 응답
{ status: TicketStatus }TicketPriorityChangeRequest / TicketPriorityChangeResponse (#046)
// 요청 — 상태머신 없어 모든 값으로 자유 전이(전이 제약·409 없음). 동일값 멱등 200. 미존재 시 404 TICKET_NOT_FOUND.
{ priority: TicketPriority }
// 응답
{ priority: TicketPriority }changeTicketPriority(PATCH /api/v1/admin/tickets/{id}/priority). FE 훅 useChangeTicketPriority,
요청 enum TicketPriorityChangeRequestPriority(값은 TicketPriority 와 동일). 변경 이력 미기록(상태/담당자
변경과 동일 관례).
TicketCategoryChangeRequest / TicketCategoryChangeResponse (#167)
// 요청 — category=null 이면 미분류로 해제. 상태머신 없어 모든 값으로 자유 변경(전이 제약·409 없음).
// 동일값 재설정 멱등 200(updatedAt 갱신). 미존재 시 404 TICKET_NOT_FOUND.
{ category?: TicketCategory | null }
// 응답
{ category?: TicketCategory | null }changeTicketCategory(PATCH /api/v1/admin/tickets/{id}/category, OPERATOR-only). FE 훅
useChangeTicketCategory, 요청 enum TicketCategoryChangeRequestCategory(값은 TicketCategory 와 동일 ·
nullable). audit TICKET_CATEGORY_CHANGED. FE 상세(ticket-detail-client)는 “미분류”(빈 값=null) 포함
select 로 재분류 — 성공 시 상세 invalidate(router.refresh).
TicketAssignRequest / TicketAssignResponse
// 요청 — assigneeOperatorId 생략(omit, `{}`) = 담당자 해제.
// #028 부터 스키마가 nullable(`assigneeOperatorId?: string | null`). FE 는 해제 시 omit 으로 보낸다(관례 유지).
{ assigneeOperatorId?: string | null }
// 응답
{ assigneeOperatorId?: string | null }Ticket enums (#027)
| enum | 값 |
|---|---|
TicketStatus (= ListStatus) | OPEN · IN_PROGRESS · RESOLVED · CLOSED |
TicketPriority (= ListPriority) | URGENT · HIGH · NORMAL · LOW |
CommentKind | REPLY · INTERNAL |
generated 는 응답/요청별로 동일값 enum 을 분리 emit(
TicketDetailStatus·TicketListItemStatus·TicketStatusChangeRequestStatus등)하지만 값은 모두 동일하다. FE 는ticket-meta.ts에서 표시용 단일 메타(STATUS_META·PRIORITY_META)로 통합한다.
Music DTOs (#041/#042 — /api/v1/admin/music*)
음원 파일 업로드 v1(#041) + 카탈로그 조회·소프트삭제(#042). 업로드/교체 요청은 multipart/form-data
— 파일 파트(file)와 메타(query) 가 분리된다. generated 는 파일 파트를 *Body({ file: Blob }), 메타를
*Params(query) 로 emit 한다. 조회/삭제는 일반 JSON — 목록은 envelope MusicListResponse, 상세는
MusicResponse 재사용, 삭제는 204 No Content(바디 없음).
MusicResponse (uploadMusic 201 · replaceMusicFile 200 · getMusic 200)
상세 응답. #042 getMusic 도 이 DTO 를 재사용한다(재생 url 인 audioUrl 포함 — 목록 항목과의 차이).
{
id: string; // 음원 id (= blob 파일명 stem). 앱 선생성 시간순 UUID = PK (A안)
title: string; // 곡 제목 (TIT2 재기록 값)
audioUrl: string; // 저장된 음원 url (어댑터별 규약 문자열 — Local / Azure Blob). 상세 전용
durationSeconds: number; // 곡 길이(초). 클라이언트가 추출해 전달 (DB 컬럼만, ID3 미기록)
musicSource: "AI" | "TRUST"; // 음원 타입. 업로드 시 지정·불변 (#059)
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}MusicListResponse (listMusic 200)
목록 envelope. 감사(#034) 페이지네이션 패턴 재사용 — { items, page, size, total }. total 은
활성(deleted_at IS NULL) 음원 전체 건수.
{
items: MusicListItem[]; // 현재 페이지 음원 행 (정렬 created_at DESC, id DESC 고정)
page: number; // 0-base 페이지 번호
size: number; // 페이지 크기 (1..100 으로 clamp)
total: number; // 활성 음원 전체 건수
}MusicListItem (목록 항목)
목록 행 요약. audioUrl 제외 — 목록은 브라우징용이고 재생 url 은 상세(getMusic → MusicResponse)
에서만 노출한다(불필요한 url 누출·payload 절감).
{
id: string; // 음원 id
title: string; // 곡 제목
durationSeconds: number; // 곡 길이(초)
musicSource: "AI" | "TRUST"; // 음원 타입 (#059). 행 배지·타입 필터에 사용
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
// audioUrl 없음 — 상세에서만 제공
}ListMusicParams (GET /api/v1/admin/music query)
type ListMusicParams = {
q?: string; // 곡 제목 부분검색(대소문자 무시, ILIKE). 0..100자
musicSource?: "AI" | "TRUST"; // 음원 타입 필터 (#059). 미지정 시 전체 타입
page?: number; // 페이지 번호 (0-base). 기본 0
size?: number; // 페이지 크기. 기본 20, 1..100 으로 clamp
};목록은 활성만 노출. soft-deleted·미존재 단건 조회/삭제는 모두 404
MUSIC_NOT_FOUND(존재 은닉). 삭제(deleteMusic) 는 204 No Content — 응답 바디 없음,deleted_at만 채우고 blob 유지.
UploadMusicBody / UploadMusicParams (POST /api/v1/admin/music)
// multipart 파일 파트
type UploadMusicBody = { file: Blob }; // 음원 MP3 파일 (최대 20MB)
// query params (클라이언트가 파일에서 추출)
type UploadMusicParams = {
title: string; // 곡 제목 — 필수. TIT2 재기록 값
durationSeconds: number; // 곡 길이(초) — 필수
musicSource: "AI" | "TRUST"; // 음원 타입 — 필수·불변 (#059)
};
title누락·빈 파일·20MB 초과·musicSource누락/오값 → 400MUSIC_INVALID_FIELD· 비-MP3/손상 ID3 → 400METADATA_PARSE_FAILED.
ReplaceMusicFileBody / ReplaceMusicFileParams (PUT /api/v1/admin/music/:id/file)
// multipart 파일 파트
type ReplaceMusicFileBody = { file: Blob }; // 새 음원 MP3 파일
// query params
type ReplaceMusicFileParams = {
title?: string; // 곡 제목 — 미제공 시 기존 유지
};같은 blob key 덮어쓰기(파일 이력 없음). 음원 미존재 → 404
MUSIC_NOT_FOUND. ID3 태그 셋·식별자 통일·재기록 파이프라인 상세는 Music Upload 참조.
Music Tag Option DTOs (#132 — /api/v1/admin/music-tag-options*)
장르·무드 태그 옵션 도메인(#132). 라이브러리·플레이리스트 분류에 쓰이는 GENRE/MOOD 옵션 CRUD.
type enum 은 GENRE(장르)·MOOD(무드). 목록은 페이지네이션 없음(타입별 소규모)·sort_order ASC, value ASC 고정 정렬·비활성 포함. 삭제는 soft delete(active=false, 멱등).
MusicTagOptionResponse (createMusicTagOption 201 · updateMusicTagOption 200 · 목록 항목)
{
id: string; // 옵션 id
type: "GENRE" | "MOOD"; // 옵션 타입. 생성 후 불변
value: string; // 옵션 값(태그명). 1..50자
sortOrder: number; // 타입 내 표시 순서(오름차순). 미지정 생성 시 0
active: boolean; // 활성 여부. false = soft-deleted/비활성
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}MusicTagOptionListResponse (listMusicTagOptions 200)
목록 envelope. 페이지네이션 없음 — { items } 만(타입별 옵션 수 소규모). 정렬 sort_order ASC → value ASC(결정적), 비활성 옵션 포함(운영자 관리 화면).
{
items: MusicTagOptionResponse[]; // 해당 타입의 옵션 목록 (sort_order ASC, value ASC)
}ListMusicTagOptionsParams (GET /api/v1/admin/music-tag-options query)
type ListMusicTagOptionsParams = {
type: "GENRE" | "MOOD"; // 옵션 타입 — 필수
};CreateMusicTagOptionRequest (POST /api/v1/admin/music-tag-options)
{
type: "GENRE" | "MOOD"; // 옵션 타입 — 필수·불변
value: string; // 옵션 값. 1..50자 (@NotBlank @Size)
sortOrder?: number | null; // 표시 순서 — 미지정/null 시 0
}생성 시
active=true. 같은 타입 내 동일value중복 → 409MUSIC_TAG_OPTION_DUPLICATE.
UpdateMusicTagOptionRequest (PATCH /api/v1/admin/music-tag-options/:id)
부분 갱신 — 세 필드 모두 null(미전송)이면 no-op(현재 상태 반환). type 은 불변(변경 불가).
{
value?: string | null; // 값(null/미전송 = 미변경). 1..50자
sortOrder?: number | null; // 표시 순서(null = 미변경)
active?: boolean | null; // 활성 여부(null = 미변경)
}미존재 → 404
MUSIC_TAG_OPTION_NOT_FOUND·value변경 시 중복 → 409MUSIC_TAG_OPTION_DUPLICATE. 삭제(deleteMusicTagOption) 는 204 No Content —active=false로 soft delete(이미 비활성이어도 멱등). 운영사 UI 상세는 설정 18-3 참조.
Library DTOs (#053 — /api/v1/admin/libraries*)
라이브러리 도메인(#053) — 음원→라이브러리 2계층의 중간 층. 라이브러리 = 타입(AI/TRUST) 묶음의 음원
컬렉션. CRUD + 음원 할당/해제(M:N). 목록·곡 목록 envelope 는 { items, page, size, total }(감사 #034
패턴). libraryType enum 은 AI(AI 생성)·TRUST(신탁) — 색만으로 구분하지 않고 UI 에서 라벨 병기.
LibraryResponse (createLibrary 201 · getLibrary 200 · updateLibrary 200)
상세/단건 응답. musicCount(담긴 활성 음원 수) 포함 — 목록 항목과의 차이.
{
id: string; // 라이브러리 id
name: string; // 라이브러리 이름 (1..255)
libraryType: "AI" | "TRUST"; // 타입. 생성 시 고정(이후 불변). 단일 타입 묶음(혼합 불가)
musicCount: number; // 담긴 활성 음원 수 (상세 전용)
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}LibraryListResponse / LibraryListItem (listLibraries 200)
목록 envelope. total 은 활성(deleted_at IS NULL) 라이브러리 전체 건수. 항목 LibraryListItem
은 곡 수 제외(곡 수는 상세 getLibrary 에서만).
type LibraryListResponse = {
items: LibraryListItem[]; // 현재 페이지 (정렬 created_at DESC 고정)
page: number; // 0-base
size: number; // 1..100 으로 clamp
total: number; // 활성 라이브러리 전체 건수
};
type LibraryListItem = {
id: string;
name: string;
libraryType: "AI" | "TRUST";
createdAt: string;
updatedAt: string;
// musicCount 없음 — 상세에서만 제공
};LibraryMusicListResponse / LibraryMusicListItem (listLibraryMusic 200)
라이브러리에 담긴 음원 목록 envelope. 항목은 음원 목록(MusicListItem)과 동형이되 audioUrl 제외.
정렬은 할당 시각 DESC 고정.
type LibraryMusicListResponse = {
items: LibraryMusicListItem[]; // 정렬 할당 시각 DESC
page: number;
size: number;
total: number; // 라이브러리에 담긴 활성 음원 전체 건수
};
type LibraryMusicListItem = {
id: string; // 음원 id
title: string;
durationSeconds: number;
createdAt: string;
updatedAt: string;
};CreateLibraryRequest / UpdateLibraryRequest / AddLibraryMusicRequest
type CreateLibraryRequest = {
name: string; // 1..255
libraryType: "AI" | "TRUST"; // 생성 시 고정
};
type UpdateLibraryRequest = {
name: string; // 1..255 (libraryType 불변 — 요청에 없음)
};
type AddLibraryMusicRequest = {
musicIds: string[]; // 0..200. 멱등 — 이미 담긴 음원은 무시
};타입 일치 enforcement (#059): 추가하려는 음원의
musicSource가 라이브러리libraryType과 다르면 전체 reject → 400LIBRARY_TYPE_MISMATCH. 위반 musicId 는error.fields.violatingMusicIds(콤마 구분 문자열)에 노출된다. 운영사 UI(/music→ 라이브러리에 추가)는 picker 를libraryType = 음원 musicSource로 미리 필터해 불일치 선택을 차단하고, 그래도 발생하는 mismatch 는 사용자 메시지(“라이브러리 타입과 다른 음원은 담을 수 없습니다”)로 매핑한다.
ListLibrariesParams / ListLibraryMusicParams (query)
type ListLibrariesParams = {
q?: string; // 이름 부분검색(ILIKE). 0..100자
libraryType?: "AI" | "TRUST"; // 타입 필터
page?: number; // 0-base, 기본 0
size?: number; // 기본 20, 1..100 clamp
};
type ListLibraryMusicParams = {
page?: number; // 0-base, 기본 0
size?: number; // 기본 20, 1..100 clamp
};라이브러리/할당 모두 활성만 노출. soft-deleted·미존재 라이브러리는 404
LIBRARY_NOT_FOUND(존재 은닉).addLibraryMusic음원 1건이라도 미존재 → 404MUSIC_NOT_FOUND(all-or-nothing). 삭제·제거는 204 No Content. 운영사 UI·2계층 모델 상세는 Library 참조.
Playlist DTOs (#054 — /api/v1/admin/playlists*)
플레이리스트 도메인(#054) — 음원→라이브러리→플레이리스트 2계층의 최상위 층. 플레이리스트 = 라이브러리
묶음(OPERATOR 소유, hqId 본사별). 곡 직접 안 담음 — 라이브러리 단위. CRUD + 라이브러리 담기/제거/
순서. 목록 envelope 는 { items, page, size, total }, 담긴 라이브러리 목록은 { items, total }(순서
전체 — 페이지네이션 없음).
PlaylistStatus (#060 — 파생 상태 enum)
플레이리스트의 파생(계산) 상태. DB 컬럼·마이그레이션 없이 응답 시 집계(libraryCount·appliedStoreCount·isDefault)로 계산한다. 한 PL 은 정확히 1개 상태이며 배타적 우선순위로 판정한다.
type PlaylistStatus = "EMPTY" | "UNUSED" | "ACTIVE" | "FALLBACK";
// 판정 우선순위 (배타적): EMPTY > FALLBACK > ACTIVE > UNUSED
// EMPTY — libraryCount == 0 (라이브러리 미보유, 최우선)
// FALLBACK — isDefault == true (본사 기본 PL, 점장 큐 fallback 대상)
// ACTIVE — appliedStoreCount >= 1 (적용 매장 있음)
// UNUSED — 그 외 (libraryCount>0 && !isDefault && appliedStoreCount==0)BE 단일 파생 함수 derivePlaylistStatus(libraryCount, appliedStoreCount, isDefault)(운영사·본사 서비스 공유). FE 는 표시만 한다(서버 파생값 신뢰). FE 상태 배지 라벨/톤(운영사·본사 일치): ACTIVE → "사용 중"(초록·success) · FALLBACK → "기본"(파랑·info) · UNUSED → "미사용"(중립·muted) · EMPTY → "비어있음"(옅은 경고·warn). MISMATCH(플랜 불일치)는 플랜 게이팅 후속(#060 F1) — 이번 enum 에 없음.
PlaylistResponse (createPlaylist 201 · getPlaylist 200 · updatePlaylist 200 · setDefaultPlaylist 200)
상세/단건 응답. libraryCount(담긴 활성 라이브러리 수) 포함 — 목록 항목과의 차이.
{
id: string; // 플레이리스트 id
hqId: string; // 소유 본사 id. 생성 시 고정(이후 불변)
name: string; // 플레이리스트 이름 (1..255)
isDefault: boolean; // (#058) 본사 기본 PL 여부. 본사당 1개. setDefaultPlaylist 후 true
libraryCount: number; // 담긴 활성 라이브러리 수 (상세 전용)
appliedStoreCount: number; // (#060) 이 PL 을 활성으로 쓰는 (소유 본사) 매장 수
status: PlaylistStatus; // (#060) 파생 상태 EMPTY|UNUSED|ACTIVE|FALLBACK
createdAt: string; // ISO-8601
updatedAt: string; // ISO-8601
}PlaylistListResponse / PlaylistListItem (listPlaylists 200)
목록 envelope. total 은 활성(deleted_at IS NULL) 플레이리스트 전체 건수. 항목 PlaylistListItem
은 라이브러리 수·본사명 제외(라이브러리 수는 상세 getPlaylist, 본사명은 UI 가 listHqs 로 해석). #060 부터 appliedStoreCount·status 를 포함한다.
type PlaylistListResponse = {
items: PlaylistListItem[]; // 현재 페이지 (정렬 created_at DESC 고정)
page: number; // 0-base
size: number; // 1..100 으로 clamp
total: number; // 활성 플레이리스트 전체 건수
};
type PlaylistListItem = {
id: string;
hqId: string; // 소유 본사 id (본사명 아님 — UI 가 해석)
name: string;
isDefault: boolean; // (#058) 본사 기본 PL 여부 — 목록 "기본" 배지 + 토글 라벨 결정
appliedStoreCount: number; // (#060) 이 PL 을 활성으로 쓰는 (소유 본사) 매장 수
status: PlaylistStatus; // (#060) 파생 상태 — 목록 상태 배지
createdAt: string;
updatedAt: string;
// libraryCount 없음 — 상세에서만 제공
};운영사 목록(/playlists)·상세(/playlists/[id])는 상태 배지(#060)와 적용 매장 수를 노출한다. status=FALLBACK 행은 상태 배지가 “기본” 을 표현하므로 별도 isDefault “기본” 배지를 숨겨 중복을 피하고, 그 외 status 인 기본 PL(예: EMPTY)은 isDefault “기본” 배지를 함께 노출한다.
PlaylistLibraryListResponse / PlaylistLibraryListItem (listPlaylistLibraries 200)
플레이리스트에 담긴 라이브러리 목록 envelope(순서 전체 — page/size 없음). 정렬은 position ASC 고정.
type PlaylistLibraryListResponse = {
items: PlaylistLibraryListItem[]; // position 순서대로
total: number; // 담긴 활성 라이브러리 전체 건수
};
type PlaylistLibraryListItem = {
id: string; // 라이브러리 id
name: string; // 라이브러리 이름
libraryType: string; // 라이브러리 타입(AI/TRUST)
createdAt: string;
updatedAt: string;
};CreatePlaylistRequest / UpdatePlaylistRequest / AddPlaylistLibraryRequest / ReorderPlaylistLibrariesRequest
type CreatePlaylistRequest = {
hqId: string; // 소유 본사. 생성 시 고정
name: string; // 1..255
};
type UpdatePlaylistRequest = {
name: string; // 1..255 (hqId 불변 — 요청에 없음)
};
type AddPlaylistLibraryRequest = {
libraryId: string; // 멱등 — 이미 담긴 라이브러리는 무시, 끝에 append
};
type ReorderPlaylistLibrariesRequest = {
libraryIds: string[]; // 0..500. 현재 담긴 라이브러리와 정확히 일치하는 새 순서
};ListPlaylistsParams (query)
type ListPlaylistsParams = {
q?: string; // 이름 부분검색(ILIKE). 0..100자
hqId?: string; // 소유 본사 필터. 미지정 시 전체
page?: number; // 0-base, 기본 0
size?: number; // 기본 20, 1..100 clamp
};플레이리스트는 활성만 노출. soft-deleted·미존재 플레이리스트는 404
PLAYLIST_NOT_FOUND(존재 은닉).createPlaylist본사 미존재 → 404HQ_NOT_FOUND·addPlaylistLibrary라이브러리 미존재 → 404LIBRARY_NOT_FOUND. 삭제·제거는 204 No Content. 운영사 UI·2계층 모델 상세는 Playlist 참조.
기본 PL 지정/해제 (#058 — setDefaultPlaylist · clearDefaultPlaylist)
요청 본문 없음(path id 만). setDefaultPlaylist(PUT)은 그 PL 을 본사 기본으로 지정하고 같은 본사의
기존 기본 PL 을 같은 트랜잭션에서 원자적 해제한다(본사당 1개 — partial unique index (hq_id) WHERE is_default AND deleted_at IS NULL) → 200 PlaylistResponse(isDefault=true). clearDefaultPlaylist
(DELETE)은 기본 지정을 해제한다(204, 멱등 — 이미 비기본·미존재여도 성공). 기본 PL 을 deletePlaylist
(#054)로 소프트삭제하면 partial index 가 deleted_at IS NULL 조건이라 is_default 도 자동 무효(별도 clear
불요). 점장 큐 fallback 은 아래 StorePlaybackQueueResponse.source 참조.
StorePlaybackQueueResponse.source (#058 — 점장 큐 fallback)
점장 큐(#056, GET /api/v1/store/playback-queue) 응답에 source 추가(하위호환 — 기존 reason 유지).
type PlaybackQueueSource = "DEVICE" | "ACTIVE" | "DEFAULT" | "NONE";
// DEVICE — (SPEC #178) 기기별 지정 PL(store_device_playlist)로 큐 생성. deviceId 를 함께 보낸 요청에서만 나온다
// ACTIVE — store.active_playlist_id 로 적용된 매장 활성 PL 로 큐 생성
// DEFAULT — 매장 활성 PL 부재 → 그 매장 본사 기본 PL(is_default)로 fallback (store row 미변경·조회 시점)
// NONE — 활성 PL 도 본사 기본 PL 도 없음 → 빈 큐, reason=NO_ACTIVE_PLAYLISTfallback 은 비파괴(공유 모델 — store.active_playlist_id 를 자동 기록하지 않고 조회 시점에만 기본 PL
로 큐를 만든다). 점장 player 가 source=DEFAULT 일 때 “기본 재생목록 재생 중”을 안내한다.
해석 우선순위 (SPEC #178) — GET /api/v1/store/queue?deviceId= 로 기기를 밝히면 기존 2단 폴백 위에
1단이 얹혀 기기 지정 PL(DEVICE) → 매장 활성 PL(ACTIVE) → 본사 기본 PL(DEFAULT) → 없음(NONE)
4단이 된다. deviceId 를 생략하면(구버전 클라이언트) 이 단계를 통째로 건너뛰어 종전 2단 폴백 그대로
동작하므로 DEVICE 를 받지 않는다(D12). 기기가 타 매장이거나 회수됐으면 무시하고 폴백한다 — 재생은 어떤
경우에도 멈추지 않아야 한다.
StorePlaybackQueueResponse.reason · total · truncated (BE #284)
reason 은 큐가 빈 이유만 담는다(곡이 있으면 null). generated 타입은 string | null 이므로 FE 는
아래 3종을 분기하고 나머지는 “본사 준비 중” 기본 분기로 흘린다.
reason | 의미 | 점장 player 표시 |
|---|---|---|
NO_ACTIVE_PLAYLIST | 매장 활성 PL 도 본사 기본 PL 도 없음(source=NONE) | “본사가 플레이리스트를 준비 중입니다” |
EMPTY_PLAYLIST | PL 은 적용됐는데 담긴 곡이 0 | ”재생할 곡이 없습니다” (곡 추가 안내) |
UNPLAYABLE_SOURCES | PL 에 곡은 있는데 전부 재생 불가한 음원 URL(http(s) 아닌 local:// 등)이라 서버가 전부 제외 | ”플레이리스트의 음원 파일을 재생할 수 없습니다” + 플레이리스트명 + 고객지원 문의 유도 |
UNPLAYABLE_SOURCES 를 EMPTY_PLAYLIST 로 뭉개면 점장에게 거짓 안내(“곡을 추가하면 재생됩니다”)가
나가고, 실제 원인(음원 파일·스토리지)은 매장에서 손댈 수 없다. 그래서 별도 코드로 분리해 운영자 확인
경로(문의)로 보낸다 — 상세는 Store Player §source/빈 상태.
total= 상한(500)·재생 불가 필터를 모두 적용한 뒤의 곡 수(=items크기).truncated= 필터 이전 후보 수가 상한을 넘었는지. 따라서total < 500이면서truncated=true인 조합은 버그가 아니라 정상이다(PL 규모는 큰데 재생 가능한 곡이 적다는 신호).
StorePlaybackQueueResponse — 시간대 슬롯 확장 (SPEC #171)
GET /api/v1/store/queue 응답을 시간대별 라이브러리 스케줄로 확장했다. 현재 슬롯과 다음 슬롯
lookahead·경계·서버 시각을 담고, 최상위 items/total/truncated 는 하위호환 미러(=currentSlot
의 것)로 유지한다(기존 player 회귀 안전망).
interface StorePlaybackQueueResponse {
active: boolean;
source: "DEVICE" | "ACTIVE" | "DEFAULT" | "NONE"; // DEVICE 는 deviceId 를 보낸 요청에서만(SPEC #178)
playlistId?: string | null;
playlistName?: string | null;
reason?: string | null;
currentSlot: QueueSlot; // 현재 시간대 슬롯(폴백=전체 셔플이면 libraryId 등 null, items 는 채워짐)
nextSlot?: QueueSlot; // 다음 슬롯 lookahead(크로스페이드/전환용). 폴백·하루 끝이면 null
nextBoundaryMinute?: number | null; // 현재→다음 슬롯 경계(KST 분, [0,1440]). 경계 없음이면 null. ⚠️ 1440="다음날 0:00"(자정)
serverNowIso: string; // 서버 현재 시각(ISO-8601, KST 오프셋) — FE 시계 오프셋(드리프트 무력화)
// 하위호환 미러 — currentSlot 의 것
items: QueueItem[];
total: number;
truncated: boolean;
}
type QueueSlot = {
libraryId?: string | null; // 슬롯 라이브러리 id (전체 셔플 폴백이면 null)
libraryName?: string | null;
startMinute?: number | null; // 슬롯 시작 (KST 분, 30분 배수·포함). 폴백이면 null
endMinute?: number | null; // 슬롯 끝 (KST 분, 30분 배수·미포함). 폴백이면 null
items: QueueItem[]; // 비셔플 재생 큐 항목 (앱이 로컬 셔플·반복)
total: number;
truncated: boolean;
} | null;
// query (generated `getStorePlaybackQueueParams.ts`)
type GetStorePlaybackQueueParams = {
deviceId?: string; // (SPEC #178) 이 요청을 보내는 기기 id. 주면 기기 지정 PL 이 최우선(source=DEVICE). 생략 = 종전 매장 단위
};currentSlot.items는 비셔플이며 앱이 로컬 셔플한다(종전엔 서버가 셔플했다). 앱은 슬롯을 손에 쥐고 소진 시 재셔플·반복하며, 서버를 다시 때리지 않는다(BE 는 요청마다 라이브러리 전곡을 로드·셔플).- 경계 판정(FE):
nextBoundaryMinute을 절대 epoch(ms)로 환산하고(KST 자정 epoch + 분*60000),serverNowIso기반 서버시각 오프셋으로 현재시각을 계산해 비교한다.1440은 자정으로 day-wrap 된다(nowMin >= 1440판정은 자정 이후 wrap 으로 영영 전환하지 못하므로 금지). BE 는 nextSlot 에 이미 wrap 을 적용해 자정 다음 슬롯을 담아 보내며,nextBoundaryMinute값 자체는 1440 을 유지한다. - 소비 상세는 Store Player §시간대 슬롯 전환 참조.
Store Active Playlist (#055 — 매장 적용 층)
음악 2계층(음원→라이브러리→플레이리스트)의 매장 적용 층(StoreActivePlaylist). 매장당 단일
활성·공유 모델(PL 참조, 복사본 아님).
⚠️ #170 — 운영사 진입점 orphan. 운영사(OPERATOR) 매장별 활성 PL 지정(
getStoreActivePlaylist·setStoreActivePlaylist·clearStoreActivePlaylist=/api/v1/admin/stores/{id}/active-playlist)은 UI 가 제거돼 호출부 없음(BE 후속 cleanup). 활성 PL 을 실제로 정하는 경로는 점장 self (setStoreOwnActivePlaylist,PATCH /api/v1/store/me/active-playlist, #129) — 아래 응답 DTO (StoreActivePlaylistResponse)를 그 경로가 재사용한다.
StoreActivePlaylistResponse (getStoreActivePlaylist 200 · setStoreActivePlaylist 200)
적용 PL 요약. 미적용 시 active=false·나머지 null. appliedAt 은 활성 PL 을 적용한 시각 전용 값(store.active_playlist_applied_at, V52) — 종전엔 store.updatedAt 근사라 매장명 변경 등 무관한 갱신에 끌려다녔다(BE #284).
{
active: boolean; // 활성 PL 적용 여부 (미적용 시 false)
playlistId?: string | null; // 적용된 PL id (미적용 시 null)
name?: string | null; // 적용된 PL 이름 (미적용 시 null)
libraryCount?: number | null;// 담긴 활성 라이브러리 수 (미적용 시 null)
appliedAt?: string | null; // 적용 시각(store.active_playlist_applied_at V52, ISO-8601, 미적용 시 null)
}SetStoreActivePlaylistRequest (setStoreActivePlaylist body)
{
playlistId: string; // 적용할 PL id. 매장 소속 본사(hqId)의 활성 PL 이어야 함
}적용 대상 PL 의 본사(
playlist.hqId)가 매장 본사(store.hqId)와 다르면 409STORE_PLAYLIST_HQ_MISMATCH. PL 미존재·소프트삭제 → 404PLAYLIST_NOT_FOUND· 매장 미존재 → 404STORE_NOT_FOUND. 해제(clearStoreActivePlaylist)는 204 No Content·멱등. FE 는 후보 PL 을 매장 hqId 로 한정(useListPlaylists({ hqId }))해 mismatch 를 사전 차단한다. 매장 상세 UI 는 Store 상세 참조.
ErrorResponse
{
success: false;
error: {
code: string;
message: string;
details?: Array<{ field: string; code: string; message: string }>;
};
}TypeScript import 패턴
import type {
AuthResponse,
MeResponse,
HqOnboardingRequest,
} from "@linkmusic/api-client";
// 권장: 항상 package root 에서 import (generated 내부 경로 직접 import 금지)
// — `@linkmusic/api-client` 가 `export * from "./generated"` 로 재-export 한다.수기 type 재정의 금지 — generated schema 만 사용 ([[feedback-15]]). 변경 시 pnpm sync-api.
Roadmap
- DTO 카탈로그 자동 생성 (OpenAPI → MDX)
- DTO 별 예제 JSON
References
linkmusic-frontend-space/packages/api-client/src/generated/schemas/linkmusic-frontend-space/packages/api-client/src/generated/endpoints/linkmusic-msa-space-was/.../api/*/dto/- OpenAPI:
/v3/api-docs