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 900 = 15min)
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)
SetupVerifyRequest { token: string }
SetupVerifyResponse { email: string; role: "OPERATOR"|"HQ_MANAGER"|"STORE_MANAGER"; expiresAt: string }
SetupCompleteRequest { token: string; newPassword: string } // → 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.
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)
{
hq: {
name: string;
businessNumber?: string; // SPEC #013, ###-##-##### 패턴
plan: "AI" | "TRUST";
billingAnchorDay: number; // 1~31
llmKeywords?: string;
llmMaxPerHour: number; // 1~12
};
consent: { termsVersion: string; privacyPolicyVersion: string };
manager: { email: string; tempPassword: string; name: string };
}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 계정.
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 (#011)
{
store: {
name: string;
address?: string;
managerName?: string;
managerEmail?: string;
managerPhone?: string;
plan: "AI" | "TRUST";
billingAnchorDay: number;
};
consent: { termsVersion: string; privacyPolicyVersion: string };
}StoreOnboardingResponse
{ storeId: string }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; // 적용 시각 근사 (store 갱신 시각, 미적용 시 null, ISO-8601)
}매장 활성 PL 요약. 운영사 getStoreActivePlaylist/setStoreActivePlaylist(#055)와 점장
setStoreOwnActivePlaylist(#129)가 공유하는 응답 DTO. 미적용(본사 기본 fallback) 이면 active=false·
나머지 null.
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) distinct 매장 수
}operationId getHqUndeliveredToday(D4). 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 강조(클릭 시 /admin/audit), 0 이면 “모두 도달” 비강조.
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 HqStoreDetailResponse(read-back, D1) — FE 가 새 매장 상세 페이지(/admin/stores/{id})로 바로 push 하므로 추가 detail fetch 0. 검증 위반 시 400 HQ_STORE_INVALID_FIELD(jakarta validation @Valid + service 명시 가드). 정지(SUSPENDED) 본사는 등록 거부 → 403 AUTH_HQ_SUSPENDED (login 차단이 first line, service 가드 second line — D5). 동일 본사 내 name 중복 허용(분점 패턴, D6). 점장(STORE_MANAGER) 계정 발급은 분리(D3 — 운영자 또는 후속 F5). audit hq_audit_log 1행 기록: HqAuditAction.HQ_STORE_CREATED 신규(총 9종) + 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; // 등록 실패 행 수
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)
errorMessage?: string | null; // 실패 사유 안내 문구(사용자 안전 문구)
}operationId bulkCreateHqStores. HQ_MANAGER-only. consumes multipart/form-data(@RequestPart("file")). #106 단일 등록을 행 단위로 미러하되 MVP 5필드(점장 발급·영업시간 제외, 후속). 부분 실패 = 행별 독립 처리 + 성공분 커밋 + 결과 리포트(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).
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 — 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
}생성(201)·상세(200) 공통 응답. FE 목록 행은 [재생] 시 상세를 lazy fetch 해 audioUrl 로
<audio controls autoPlay src={audioUrl}> 재생(N+1 회피).
TtsAnnouncementListResponse / TtsAnnouncementListItem (#061·#063·#066·#097 — 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매장)` 보조 표기.
}#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`·`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 4종) "SCHEDULED" | "PENDING" | "PLAYED" | "CANCELED"
createdAt: string; // 송출 row 생성 시각 (ISO-8601, 예약 등록 시각 = SCHEDULED 의 등록 시각)
playedAt?: string | null; // 재생(ack) 시각 (ISO-8601). SCHEDULED/PENDING/CANCELED 동안 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 만 카운트
distinctStoreCount: number; // (SPEC #075) 고유 매장 수 — COUNT(DISTINCT storeId), 매장 0건이면 0
}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 동일).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 큐 사이에 사이클 삽입”
하는 다른 도메인이다(F1 후속에서 재생기 연동). 본 슬라이스는 백본 + 본사 관리 UI 만 — 점장 player
삽입·CM 라이브러리·audit 은 F1·F2·F3 후속.
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; // Azure blob 등의 공개 오디오 URL (≤2048자)
durationSeconds: number; // 오디오 길이(초) — 1..3600
isActive: boolean; // 활성 여부 — F1 점장 player 사이클 삽입 대상 토글
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
};CreateHqCommercialRequest (#093 — POST /api/v1/hq/commercials)
// generated `schemas/createHqCommercialRequest.ts`
CreateHqCommercialRequest {
title: string; // 1~200
audioUrl: string; // ≤2048 (URL 형식)
durationSeconds: number; // 1~3600
}201 +
HqCommercialDetailResponse. 생성 시isActive=true기본(F1 사이클 삽입 대상). audioUrl 은 FE 가 음원 파일 업로드(별도 hook 후속) 후 받은 Azure blob URL — 본 슬라이스는 음원 업로드 UI 미포함, 사용자가 공개 접근 URL 을 직접 입력한다(SPEC §D9 가드). hqId·createdAt·updatedAt 은 BE 가 토큰 주체·now()로 채운다(요청 본문 X — 타 본사 격리).
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 변경은 후속(D2 — 재업로드 흐름 별도). FE 편집 form 은 변경 없으면 [저장] disabled. hqId ≠ 주체/미존재/삭제 → 404COMMERCIAL_SONG_NOT_FOUND(존재 은닉, BE D3).
삭제는
deleteHqCommercial(204 soft-delete, body 없음,deleted_at채움). 404COMMERCIAL_SONG_NOT_FOUND(affected=0 — 이미 삭제·미존재·타 본사 모두 은닉). FE 는 상세 4번째 섹션의 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[]; // 생성순 오름차순(시간순)
}타 본사 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단계만 |
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 만
}본사 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 후속).
}operationId
listStorePendingAnnouncements(200). 본인 매장의 PENDING 송출만(created_at ASC).apps/space/storeplayer 가 20초 폴링해 곡 끝에 가장 오래된 것부터 1회 삽입 재생. ack 은ackStoreAnnouncement(204·멱등, 본문 없음).revokedDispatchIds(SPEC #077 확장)는 본사 원격 즉시중단(revoke) 신호로, player 가 재생 중 멘트 즉시 중단·재선출 차단에 쓴다(ack 안 함, Store Player).DispatchStatusenum(enums).
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)
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 }`.
}
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)이 잡아 곡 끝에 삽입 재생→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.
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 (7종)
targetType: StoreAuditListItemTargetType; // 아래 enum
targetId?: string | null;
targetLabel?: string | null;
detail?: string | null;
}StoreAuditListItemAction (#115 — 점장 액션 7종, StoreAuditAction #114 미러)
"STORE_TICKET_CREATED" // 티켓 생성
"STORE_TICKET_COMMENT_ADDED" // 티켓 댓글
"STORE_TICKET_CLOSED" // 티켓 확인 종료 (#121)
"STORE_PROFILE_UPDATED" // 프로필 수정
"STORE_PASSWORD_CHANGED" // 비밀번호 변경
"STORE_DISPATCH_CANCELED" // 송출 취소
"STORE_ACTIVE_PLAYLIST_CHANGED" // 활성 PL 변경 (#129 — 선택·해제)FE 톤: 티켓 생성/댓글·프로필 수정·활성 PL 변경 = info(정상 활동), 티켓 종료 = success, 비밀번호 변경·송출 취소 = warn(보안 민감·부정 종착). 색만으로 구분하지 않고 한글 라벨 병기.
StoreAuditListItemTargetType (#115 · #129)
"DISPATCH" // 송출
"STORE_MANAGER" // 점장 계정
"SUPPORT_TICKET" // CS 티켓
"PLAYLIST" // 플레이리스트 (#129 — 활성 PL 변경 대상)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[]; // 채팅형 스레드
}TicketCommentItem
{
id: string;
authorOperatorId: string;
kind: CommentKind; // REPLY(고객 응답) | INTERNAL(내부 메모)
body: string;
createdAt: string;
}TicketCreateRequest / TicketCreateResponse
// 요청
{ title: string; body: string; priority: TicketPriority; hqId?: string; storeId?: string }
// 응답
{ id: string }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 와 동일). 변경 이력 미기록(상태/담당자
변경과 동일 관례).
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 = "ACTIVE" | "DEFAULT" | "NONE";
// 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 일 때 “기본 재생목록 재생 중”을 안내할 수 있다(player UI
는 후속 — #058 F2). 큐 엔드포인트·DTO 전체는 점장 player 도착 시 문서화 예정.
Store Active Playlist (#055 — 매장 적용 층)
음악 2계층(음원→라이브러리→플레이리스트)의 매장 적용 층(StoreActivePlaylist). 운영사가 매장에 본사
플레이리스트 1개를 활성으로 적용/해제/조회한다. 매장당 단일 활성·공유 모델(PL 참조, 복사본 아님).
StoreActivePlaylistResponse (getStoreActivePlaylist 200 · setStoreActivePlaylist 200)
적용 PL 요약. 미적용 시 active=false·나머지 null. appliedAt 은 store 갱신 시각 근사.
{
active: boolean; // 활성 PL 적용 여부 (미적용 시 false)
playlistId?: string | null; // 적용된 PL id (미적용 시 null)
name?: string | null; // 적용된 PL 이름 (미적용 시 null)
libraryCount?: number | null;// 담긴 활성 라이브러리 수 (미적용 시 null)
appliedAt?: string | null; // 적용 시각 근사(store.updatedAt, 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