Store Mode — 점장 CS 티켓 (apps/space /store/support)
SPEC #112 도입(점장 CS 티켓 — 작성·조회·상세·댓글). 점장(STORE_MANAGER)이 운영사에 문의·요청·
이슈를 보고하는 채널. 본사 CS(#086)의 격리 service + view DTO 분리 패턴을 1:1 미러하되, 점장은
본인 매장(store_id) 스코프에 격리되고 작성 시 category 가 필수다.
점장 모드(
apps/space)의 실 STORE_MANAGER 화면이다. 본 표면 범위는 본인 매장 티켓의 작성· 조회·상세·댓글(read+create+comment)뿐이며 상태/우선순위 변경은 운영자 전담(점장 무권한, 읽기 전용). 본사(#108)와 달리 점장은 상태/우선순위 액션을 받지 않는다(점장 IT 비숙련 — PRD Page 8).
⚠️ 과노출 차단 정책 변경(#173): #112 도입 시 점장 티켓은
hq_id=null로 본사 화면에서 격리돼 운영사만 조회·처리했다. #173 부터 점장 티켓에 소속 본사hqId가 부여(신규는 생성 시, 기존은 V56 백필)돼 본사도 하위 매장 CS 로 조회·답변·상태 변경한다(assign·우선순위는 여전히 운영사 전담). 점장 화면·작성 플로우는 변화 없다 — 응답 주체가 운영사에 더해 본사로 늘어난다. 본사 화면은 HQ Mode 하위 매장 CS (/admin/store-support) 참조.
정책 (SPEC #112)
- 점장 → 운영사 문의: 점장 티켓은 본인 매장이 자동 지정되며(
store_id토큰 주체 도출), 운영자 백오피스(/api/v1/admin/tickets)에서 트리아지·답변한다. - 점장은 작성·조회·댓글 + 확인 종료(/close) — 우선순위·기타 상태 변경 권한은 없다(운영자 전담). 단 SPEC #121 로 점장에게 확인 종료(RESOLVED→CLOSED) 1종만 허용한다(아래 “확인 종료” 참조). 그 외 상세는 읽기 전용 메타 사이드만 노출(처리 액션 섹션 없음).
- 우선순위 미노출: 작성 시 점장은 우선순위를 지정하지 않는다 — 서버가 NORMAL 고정(운영자가 트리아지). 본사 작성 폼의 URGENT 제외보다 더 보수적.
- category 필수 (D7):
TicketCategory5종 —PLAYBACK(재생·음악)·BROADCAST(방송·안내)·BILLING(결제·정산)·ACCOUNT(계정·로그인)·OTHER(기타). 점장 작성 시 필수(@NotNull). 목록은 category 필터 지원, 상세는 category 배지 노출. category 컬럼은 공용 ticket 테이블에 nullable 로 추가(V35) — 기존 운영자/본사 티켓 호환. 카테고리 자동 선택(추론)은 후속 — v1 은 점장 수동 선택. - audit 누적 (SPEC #114, #112 audit tail 마감): 점장 ticket 생성과 댓글 추가 가 각각
store_audit_log(V36)에 1건 기록된다 —STORE_TICKET_CREATED(detailtitle=...·category=NAME· bodyLength=N)·STORE_TICKET_COMMENT_ADDED(detailbodyLength=N), 둘 다target_type=SUPPORT_TICKET·target_id=ticket.id(댓글도 ticket scope)·target_label=ticket.title스냅샷.StoreTicketService의 create/comment 끝에서 같은 트랜잭션 hook(audit INSERT 실패 시 ticket/댓글 저장도 함께 롤백 — 원자성). 운영자가 점장 모드로 위장해 작성한 경우actor_role=OPERATOR_IMPERSONATING+ 원본 운영자 스냅샷도 함께 기록. #112 가 백본 부재로 생략했던 audit 을 마감. 본문 자체는 audit 에 두지 않고bodyLength시그널만. v1 은 기록만(조회 view 는 후속). 상세는 Store 감사.
격리 (보안 불변식)
- D1 과노출 차단: 점장 티켓은
store_id만 채우고hq_id는 null — 본사 CS 화면(#086findHqList=WHERE hq_id)에 점장 문의가 섞이지 않는다. 운영자 백오피스에서만 식별. - D2 격리 우회 차단: 목록·상세·댓글 모두
WHERE store_id = :storeId강제. 타 매장 티켓 id 도 404 로 은닉(존재 흘림 차단). - D3 INTERNAL 메모 격리: 상세 댓글은
kind=REPLY만 노출 — 운영자 INTERNAL 메모는 점장에게 보이지 않는다(본사 #086 미러).
Overview
STORE_MANAGER 가 /store/support 에 진입하면 자기 매장(storeId 토큰 주체 도출)이 작성한 CS 티켓을
시간 역순으로 본다. [문의하기]로 신규 티켓을 만들고(제목·분류·내용), 행을 클릭해 상세에서 운영사
답변 스레드를 확인·자기 댓글을 덧붙인다.
시안 출처: design_14 핸드오프(Phase 8 §8G-②
store-support.jsx)로 시안 정합 완료. 골격은 본사/admin/support(#086) idiom(공용ListToolbar/ListPagination+ client state + 채팅형 댓글 스레드)을 이어받되 정식 시각으로 교체했다 — category 큰 칩 5종(필수·?category=사전선택), 댓글 좌우 말풍선(STORE_MANAGER 우측 primary-soft / OPERATOR 좌측 surface-2), 확인 종료(/close) 1종만·우선순위 미노출. 서브페이지 헤더는 신규 공용 atomStoreSubHeader(/support·/profile·/playlist·/scheduled공용). 시각만 교체 — 동작·계약·라우팅·점장 가독성 보존. [[feedback_design_only_from_handoff]].
진입점
- 점장 player 헤더(
/store): 우상단 [고객지원] 링크(store-player-support-link,lucide:LifeBuoy) →/store/support. 사이드바가 없는 점장 셸이라 헤더에 둔다(프로필 옆). - 점장 프로필(
/store/profile): “고객지원” 섹션의 [운영사에 문의하기] 카드 (store-profile-support-link) →/store/support. 두 곳에서 discoverable.
페이지 1 — 목록 (/store/support)
apps/space/src/app/store/support/page.tsx(server 셸) + store-ticket-list-client.tsx(client).
서버사이드 페이지네이션 + client-query(useListStoreTickets) — q·status·category·page 를 client
state(applied* ↔ *Draft)로 보유. 정렬은 서버 고정(updated_at DESC, id ASC).
- 헤더: 제목 “고객지원” + 부제(
내 문의 N건 · 운영사에 도움을 요청하세요,isError면 “문의 목록을 불러오지 못했습니다”로 대체 — “0건” 오인 방지, SPEC #154) + 우측 [문의하기] 버튼(store-ticket-create-open,lucide:Plus,size=lg) →/store/support/new네비. - 툴바: 공용
ListToolbar— 제목 검색(≤100, OpenAPI 일치) + status select + category select- 적용/초기화. draft↔applied 분리.
- 표 4컬럼 (
StoreTicketListItem) — 본사 5컬럼에서 우선순위 컬럼 제거(점장 미노출), category 추가:- 제목 (font-medium)
- 상태 배지 (
StatusPill4종 — 텍스트 병기):OPEN=“신규”(info) ·IN_PROGRESS=“처리 중”(info) ·RESOLVED=“답변 완료”(success) ·CLOSED=“종료”(muted). 전수 강제 Record. - 분류 배지 (
StatusPill5종): 재생·음악 / 방송·안내 / 결제·정산 / 계정·로그인 / 기타. category 미설정(nullable) 행은 ”—”. - 작성 시각 (
createdAt,formatKstDateTime, 우측 정렬)
- 행 동작: 행 클릭/Enter/Space →
/store/support/{id}상세.tabIndex=0+aria-label+ focus-visible ring. - 빈 상태:
total=0+ 필터 없음 → “아직 문의가 없습니다.” · 필터 있음 → “조건에 맞는 문의가 없습니다.” + [필터 초기화].total>0인데 이 페이지 0 행 → “이 페이지에 표시할 문의가 없습니다.” + [첫 페이지로]. - 페이지네이션: 공용
ListPagination—size=20. - 에러 매핑: 공용
ErrorState([다시 시도]=refetch,store-ticket-list-error) — 401/403/5xx/BACKEND_UNREACHABLE/그 외 4xx 분리. 에러 시 툴바·표 숨기고 재시도만(SPEC #154).
페이지 2 — 작성 (/store/support/new)
apps/space/src/app/store/support/new/page.tsx(server 셸) + store-ticket-new-client.tsx(client).
storeId 는 토큰 주체 도출 — 요청 본문에 직접 지정할 수 없다(타 매장 격리).
- 폼 3 필드 (
CreateStoreTicketRequest) — 본사와 달리 우선순위 없음, category 추가:- 제목 input —
@maxLength 200· NotBlank. 빈/blank·201자 이상 → submit disabled + 보조 텍스트 · 우측 카운터N / 200. - 문의 분류 select — category 필수(미선택 placeholder “분류 선택…” + disabled option). 5종. 미선택 시 submit disabled + “문의 분류를 선택해 주세요.”.
- 내용 textarea —
@maxLength 5000· NotBlank. 동일 검증·카운터.rows={12}resize-y. 줄바꿈/공백 보존.
- 제목 input —
- 헤더: 제목 “문의하기” + 우측 목록으로.
- 푸터: [취소](목록으로 push) + [문의 보내기](submit · disabled when invalid · “보내는 중…” +
aria-busy,size=lg). - 성공 흐름: mutation 응답 201 =
StoreTicketDetailResponse→router.replace(/store/support/{id})(뒤로가기 중복 등록 차단). - 에러 매핑 (
mapCreateError):Banner tone="danger"(store-ticket-new-error).
페이지 3 — 상세 (/store/support/[id])
apps/space/src/app/store/support/[id]/page.tsx(server 셸, Next 15 params: Promise) +
store-ticket-detail-client.tsx(client). 상세 조회·댓글 추가는 generated 훅 직접 사용
(useGetStoreTicketDetail·useAddStoreTicketComment).
- 헤더: 좌측 [목록으로] + 제목(
store-ticket-detail-title, truncate) + 상태/분류 배지 + 댓글 N건. - 본문 섹션 (
store-ticket-detail-body): pre-wrap 텍스트 카드 — BE 본문 그대로. - 댓글 스레드 (
store-ticket-detail-comments) — 좌우 말풍선:STORE_MANAGER=“내 문의”(우측 정렬 · primary-soft 말풍선)OPERATOR=“운영사 답변”(좌측 정렬 · success 틴트 — 답변 도착의 긍정 신호)- 시간순(BE
createdAt asc보장)·REPLY 만(INTERNAL 메모 비노출). 0건이면 “아직 답변이 없습니다. 운영사가 확인 후 답변드립니다.”. - 추가 form (
store-ticket-comment-form): textarea(≤5000,AddStoreTicketCommentRequest.body) + [댓글 보내기]. 성공 시getGetStoreTicketDetailQueryKey(id)invalidate → 스레드 자동 갱신 + 입력 초기화. 실패 시 인라인Banner(store-ticket-comment-error).
- 메타 사이드(
store-ticket-detail-meta, 읽기 전용): 상태·분류·작성/수정 시각(KST). 우선순위 변경 UI 없음(운영자 전담) — 본사(#108) 처리 섹션을 점장은 받지 않는다. - 확인 종료(SPEC #121): status=
RESOLVED일 때만 메타 사이드 하단에 [확인 종료] 액션(store-ticket-close)을 노출한다. 그 외 상태(OPEN/IN_PROGRESS/CLOSED)에서는 미노출(기존 읽기 전용 유지). 클릭 →useCloseStoreTicket(PATCH /api/v1/store/tickets/{id}/close) → 성공 시 200 read-back(status=CLOSED) +getGetStoreTicketDetailQueryKey(id)invalidate 로 상태 배지·메타 자동 갱신(버튼은 RESOLVED 가 아니게 되어 사라짐). 점장은 close 1종만 — reopen·기타 전이는 불가(본사 #108 의 부분집합). 에러 매핑: 409TICKET_INVALID_STATUS_TRANSITION(이미 종료/비-RESOLVED race) → “이미 종료되었거나 종료할 수 없는 상태…” · 404TICKET_NOT_FOUND(미존재·타 매장 은닉) → “이미 삭제된 문의입니다.” → 인라인Banner(store-ticket-close-error). - 첨부파일 섹션(
store-ticket-attachments, 본문 아래 · BE PR #324·#325): 4 채널 공유TicketAttachments(@linkmusic/ui,surface="store"— 큰 글자·44px 타깃)에 점장 채널(useUploadTicketAttachmentFile("STORE"))만 주입한다. [파일 첨부] → 클라 사전 검증(이미지 4종·PDF · 10MB · 5개) → 4MB 이하는POST /api/v1/store/tickets/{id}/attachments(multipart 파트file), 초과는 업로드 티켓(TICKET_ATTACHMENT_STORE→ 백엔드 직접 cross-origin XHR) → 성공 시 detail invalidate 로 목록 갱신. 10MB 전 구간 업로드 가능(예전 4.5MB 프록시 벽 해소). 실패는 공유mapTicketAttachmentError(code 우선 + status 폴백 ·UPLOAD_NOT_CONFIGURED는 “큰 파일만 실패” 원인 + 4MB 이하 가능 안내)로 섹션 인라인Banner(store-ticket-attachment-error). 업로드·다운로드 모두 재생을 끊지 않는다 — 직접 업로드는 XHR, 다운로드는downloadUrl+ BFF prefix 의 같은 출처<a download>라 라우트 이동·페이지 언로드가 없다(window.location대입 금지). 썸네일 미리보기 없음(인증 경로). 가시성 배지는 운영자 채널 전용이라 점장 화면엔 없다(점장 응답에visibility필드 자체가 없다 — 존재 은닉). - 진행 중 잠금(frontend.md §18): 첨부 업로드·댓글 전송·확인 종료 진행 신호를
onPendingChange로 부모에 올린다. player 고객지원 모달은 그 동안 닫기 경로(Esc·바깥탭·X·탭 전환)를 전부 잠근다 — 진행 중 닫히면mutate콜백이 유실된 채 요청만 나가 같은 파일·같은 댓글이 중복된다. 잠금 상한(useModalPendingLock)으로 네트워크 hang dead-end 는 피한다. - 에러 매핑: 상세 조회 실패(404
TICKET_NOT_FOUND— 타 매장·미존재 은닉 · 401/403/5xx/BACKEND_UNREACHABLE/그 외)는 공용ErrorState([다시 시도]=refetch,store-ticket-detail-error, SPEC #154). (댓글/확인 종료 등 인라인 액션 실패는Banner유지.)
인가
/api/v1/store/tickets/** → hasRole("STORE_MANAGER") 1차 경계 + service verifyStoreScope
claim↔DB 재검증(storeId 토큰 주체 도출, 요청 파라미터 없음). 미인증 401 · role/소속 불일치 403
PRINCIPAL_SCOPE_MISMATCH. 보안 불변식: 모든 endpoint 에 WHERE ticket.store_id = :storeId
강제 — 타 매장 티켓 id 도 404 로 은닉(D2).
모든 호출은 generated apiFetch 가 BFF catch-all /api/backend/... 경유(토큰 서버 전용).
BE endpoint 5종 (#112 · #121)
-
GET /api/v1/store/tickets(listStoreTickets) — 본인 매장 티켓 목록. 필터 q/status/category/page/size. -
POST /api/v1/store/tickets(createStoreTicket) — 작성. body{ title, body, category }(category 필수). 응답 201StoreTicketDetailResponse. 초기 status=OPEN·priority=NORMAL·hq_id=null. 같은 트랜잭션 auditSTORE_TICKET_CREATED1건(#114). -
GET /api/v1/store/tickets/{id}(getStoreTicketDetail) — 단건 상세 + 댓글(REPLY 만). 타 매장·미존재 404. -
POST /api/v1/store/tickets/{id}/comments(addStoreTicketComment) — 점장 댓글(REPLY) 추가. 응답 201StoreTicketCommentItem(authorRole=STORE_MANAGER). 타 매장 404. 같은 트랜잭션 auditSTORE_TICKET_COMMENT_ADDED1건(#114). -
PATCH /api/v1/store/tickets/{id}/close(closeStoreTicket, SPEC #121) — 점장 확인 종료(RESOLVED→CLOSED). body 없음. 응답 200StoreTicketDetailResponse(read-back, status=CLOSED). store_id 격리 원자적 UPDATE(WHERE id AND store_id AND status='RESOLVED'). 비-RESOLVED → 409TICKET_INVALID_STATUS_TRANSITION, 미존재·타 매장 → 404TICKET_NOT_FOUND. 같은 트랜잭션 auditSTORE_TICKET_CLOSED1건(#114). -
POST /api/v1/store/tickets/{id}/attachments(uploadStoreTicketAttachment, BE PR #324) — 첨부 업로드.multipart/form-data파트file+ querycommentId?(FE 미사용). 201TicketAttachmentUploadResponse. ≤10MB · 티켓당 5개 · MIME 5종 + 매직바이트 · 분당 10회. -
GET /api/v1/store/tickets/{id}/attachments/{attId}(downloadStoreTicketAttachment, BE PR #324) — 서버 stream 다운로드(Content-Disposition: attachment). 404TICKET_ATTACHMENT_NOT_FOUND. generated 훅 미사용(mutator 가 text 파싱) — 같은 출처<a download>.
자세한 endpoint 규약은 Endpoints · DTO 는 DTOs 참조.
CS 새 답변 dot (SPEC #119 F5 · D2)
점장 셸은 사이드바가 없으므로, dot 은 player 헤더 [고객지원] Link(store-player-support-link)에
붙인다(Store Player §“CS 새 답변 dot” 참조).
getStoreSupportUnreadSignal(StoreSupportUnreadSignalResponse.latestOperatorReplyAt)을 60초 폴링
(점장 안내방송 20초 폴링과 별개 query)하고, localStorage lastSeen(lm.support.lastSeen.store.<storeId> —
useGetStoreMe().id)보다 최신 운영자 REPLY 가 있으면 버튼 우상단에 primary 도트(store-player-support-dot)를
표시한다(boolean dot, D2). /store/support 목록 진입(mount) 시 lastSeen=now 로 갱신해 해소한다(D5).
last-seen 헬퍼는 본사와 공유(apps/space/src/lib/support-last-seen.ts, role=store). localStorage 실패
시 보수적으로 dot 유지(D1 리스크 1). 점장 안내방송 배지는 만들지 않는다(D3 — player 가 PENDING 을 이미 자동 소비).
범위 밖 (v1 제외 — 후속)
첨부파일(10MB)✅ BE PR #324 로 도입(4 채널 동시) — 이미지 4종·PDF · 파일당 10MB · 티켓당 5개. 위 상세 섹션 참조.- category 자동 선택(추론): v1 은 점장 수동 선택. FR-6.5/7.5 자동 추론은 후속.
- 운영자/본사 view 의 category 표면화: V35 로 컬럼은 공용 추가했으나 이번엔 점장 채널만 소비.
- 상태/우선순위 변경: 점장 무권한(운영자 전담). 단 확인 종료(RESOLVED→CLOSED)는 SPEC #121 로 도입됨(위 참조) — reopen·우선순위·기타 전이는 여전히 제외.
전용 디자인 시안✅ design_14 §8G-② 정합 완료(시각만 교체 — roadmap/design-debt §1 기록).