FeaturesStore (매장)Store Customer Support (/store/support)

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): TicketCategory 5종 — 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(detail title=...·category=NAME· bodyLength=NSTORE_TICKET_COMMENT_ADDED(detail bodyLength=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 화면(#086 findHqList = 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종만·우선순위 미노출. 서브페이지 헤더는 신규 공용 atom StoreSubHeader(/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)
    • 상태 배지 (StatusPill 4종 — 텍스트 병기): OPEN=“신규”(info) · IN_PROGRESS=“처리 중”(info) · RESOLVED=“답변 완료”(success) · CLOSED=“종료”(muted). 전수 강제 Record.
    • 분류 배지 (StatusPill 5종): 재생·음악 / 방송·안내 / 결제·정산 / 계정·로그인 / 기타. category 미설정(nullable) 행은 ”—”.
    • 작성 시각 (createdAt, formatKstDateTime, 우측 정렬)
  • 행 동작: 행 클릭/Enter/Space → /store/support/{id} 상세. tabIndex=0 + aria-label + focus-visible ring.
  • 빈 상태: total=0 + 필터 없음 → “아직 문의가 없습니다.” · 필터 있음 → “조건에 맞는 문의가 없습니다.” + [필터 초기화]. total>0 인데 이 페이지 0 행 → “이 페이지에 표시할 문의가 없습니다.” + [첫 페이지로].
  • 페이지네이션: 공용 ListPaginationsize=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. 줄바꿈/공백 보존.
  • 헤더: 제목 “문의하기” + 우측 목록으로.
  • 푸터: [취소](목록으로 push) + [문의 보내기](submit · disabled when invalid · “보내는 중…” + aria-busy, size=lg).
  • 성공 흐름: mutation 응답 201 = StoreTicketDetailResponserouter.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 의 부분집합). 에러 매핑: 409 TICKET_INVALID_STATUS_TRANSITION(이미 종료/비-RESOLVED race) → “이미 종료되었거나 종료할 수 없는 상태…” · 404 TICKET_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 필수). 응답 201 StoreTicketDetailResponse. 초기 status=OPEN·priority=NORMAL·hq_id=null. 같은 트랜잭션 audit STORE_TICKET_CREATED 1건(#114).

  • GET /api/v1/store/tickets/{id} (getStoreTicketDetail) — 단건 상세 + 댓글(REPLY 만). 타 매장·미존재 404.

  • POST /api/v1/store/tickets/{id}/comments (addStoreTicketComment) — 점장 댓글(REPLY) 추가. 응답 201 StoreTicketCommentItem(authorRole=STORE_MANAGER). 타 매장 404. 같은 트랜잭션 audit STORE_TICKET_COMMENT_ADDED 1건(#114).

  • PATCH /api/v1/store/tickets/{id}/close (closeStoreTicket, SPEC #121) — 점장 확인 종료(RESOLVED→CLOSED). body 없음. 응답 200 StoreTicketDetailResponse(read-back, status=CLOSED). store_id 격리 원자적 UPDATE(WHERE id AND store_id AND status='RESOLVED'). 비-RESOLVED → 409 TICKET_INVALID_STATUS_TRANSITION, 미존재·타 매장 → 404 TICKET_NOT_FOUND. 같은 트랜잭션 audit STORE_TICKET_CLOSED 1건(#114).

  • POST /api/v1/store/tickets/{id}/attachments (uploadStoreTicketAttachment, BE PR #324) — 첨부 업로드. multipart/form-data 파트 file + query commentId?(FE 미사용). 201 TicketAttachmentUploadResponse. ≤10MB · 티켓당 5개 · MIME 5종 + 매직바이트 · 분당 10회.

  • GET /api/v1/store/tickets/{id}/attachments/{attId} (downloadStoreTicketAttachment, BE PR #324) — 서버 stream 다운로드(Content-Disposition: attachment). 404 TICKET_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 기록).