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).
정책 (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건 · 운영사에 도움을 요청하세요) + 우측 [문의하기] 버튼(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. - 에러 매핑:
Banner tone="danger"(store-ticket-list-error) — 401/403/5xx/BACKEND_UNREACHABLE/그 외 4xx 분리.
페이지 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). - 에러 매핑: 404
TICKET_NOT_FOUND→ “문의를 찾을 수 없습니다.”(타 매장·미존재 모두 은닉). 401/403/5xx/BACKEND_UNREACHABLE/그 외 — 동일 패턴.
인가
/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).
자세한 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): ticket 백본 공통 첨부 도메인 별도 SPEC(본사 #086 도 미구현).
- category 자동 선택(추론): v1 은 점장 수동 선택. FR-6.5/7.5 자동 추론은 후속.
- 운영자/본사 view 의 category 표면화: V35 로 컬럼은 공용 추가했으나 이번엔 점장 채널만 소비.
- 상태/우선순위 변경: 점장 무권한(운영자 전담). 단 확인 종료(RESOLVED→CLOSED)는 SPEC #121 로 도입됨(위 참조) — reopen·우선순위·기타 전이는 여전히 제외.
전용 디자인 시안✅ design_14 §8G-② 정합 완료(시각만 교체 — roadmap/design-debt §1 기록).