FeaturesHQ (본사)HQ Mode 감사 (apps/space /admin/audit)

HQ Mode — 본사 감사 (apps/space /admin/audit)

SPEC #067 도입(송출 audit 백본) + #068 확장(라이프사이클 audit) + #077 확장(송출 취소 audit) + #101 확장(ticket audit, #086 F3 마감) + #105 확장(매장 정보 편집 audit, #084 F1 마감). 본사(HQ_MANAGER)가 안내방송을 송출·생성·수정·삭제·송출 취소하거나 CS 티켓 생성·댓글·매장 마스터 정보 편집한 행위를 actor·시각·대상 스냅샷으로 append-only 기록하고 read-only 조회한다. dispatch 백본(V23 announcement_dispatch)을 actor 차원으로 확장한 audit 백본(V27 hq_audit_log)의 UI. SPEC #105 부터 액션 총 8종 · 대상 유형 3종(TTS_ANNOUNCEMENT·SUPPORT_TICKET·STORE).

본사 모드(apps/space)의 실 HQ_MANAGER 화면이다. 본 표면 범위는 본사 audit 조회 + CSV 내보내기 (read-only · 라이프사이클 4종 · CSV export #070) — 점장 즉시방송 audit·운영사 통합 화면은 범위 밖(후속).

Overview

HQ_MANAGER 가 /admin/audit 에 진입하면 자기 본사(hqId 토큰 주체 도출)의 송출 audit 행을 시간 역순으로 본다 — “누가 언제 어떤 안내방송을 어떤 매장에 송출했는지”. impersonation 컨텍스트 (운영자가 본사로 위장해 송출한 경우) 도 같은 행에 표시한다(원본 운영자 + 위장 본사 매니저 둘 다).

시안 출처: workspace parent dir design_handoff_linkmusic/briefs/requests/hq-audit-page.md (handoff 요청서 — 시안 부재). 임시 구현은 운영사 /audit/actions(SPEC #026·#031-D) idiom 미러 + 본사 /admin/announcements(#061) idiom 미러로 atom-grounded 합성. 시안 도착 시 정합 교체.

페이지 (/admin/audit)

apps/space/src/app/admin/audit/page.tsx(server 셸) + hq-audit-client.tsx(client). 본사 /admin/announcements(#061)와 같이 서버사이드 페이지네이션 + client-query (useListHqAuditDispatches) — from·to·action·actorAccountId·q·page 를 client state(applied**Draft)로 보유. 정렬은 서버 고정(occurred_at DESC, id DESC).

  • 툴바: 공용 ListToolbar — 검색(대상 라벨·상세 부분 검색, ≤100) + 기간 from/to(date input) + 액션 select + 적용/초기화. 행위자 입력은 별도 행으로 분리(아래).
  • 행위자 필터: actorAccountId select(hq-audit-filter-actor, SPEC #069 F9 ✅). mount 시 useListHqAuditActors(GET /api/v1/hq/audit/actors) 1회 호출 → 본사 audit 에 등장한 actor distinct 옵션 목록(HqAuditActorOption[]accountId·email·role(HqAuditActorOptionRole HQ_MANAGER|OPERATOR_IMPERSONATING)·occurrenceCount, occurrenceCount desc·email asc, 최대 200). 옵션 라벨 {email} · {roleLabel} · {occurrenceCount}건(예: manager@hq.example.com · 본사 매니저 · 24건, OPERATOR_IMPERSONATING 은 “운영자 위장”). 빈 옵션 “전체 행위자”(값 빈 문자열) 기본. select 값은 backend 가 정의한 accountId 라 추가 검증 불필요(자유 입력 sanitize·UUID_REGEX 제거됨). audit 없는 신규 본사(옵션 0건)는 select disabled + “감사 로그가 없습니다.” 보조 안내.
  • (HqAuditItem): 발생 시각(KST) · 액션 배지 · 대상 · 행위자 · 상세.
    • 발생 시각: occurredAt(ISO-8601 UTC) → formatKstDateTime(공용 @/lib/format) → YYYY. M. D. HH:mm(KST, 24시간제). font-mono tabular-nums.
    • 액션 배지: StatusPill 15종 (SPEC #068·#077·#101·#105·#106·#108·#078 반복예약·#144 지역) — HQ_ANNOUNCEMENT_DISPATCHED=“송출”(info), HQ_ANNOUNCEMENT_CREATED=“생성”(info), HQ_ANNOUNCEMENT_UPDATED=“수정”(info), HQ_ANNOUNCEMENT_DELETED=“삭제”(warn), HQ_ANNOUNCEMENT_DISPATCH_CANCELED=“송출 취소”(warn — 부정 종착 액션), HQ_DISPATCH_REVOKED=“송출 원격중단”(warn), HQ_DISPATCH_SCHEDULE_CREATED=“반복 예약 등록”(info), HQ_DISPATCH_SCHEDULE_CANCELED=“반복 예약 취소”(warn), HQ_TICKET_CREATED=“티켓 생성”(info), HQ_TICKET_COMMENT_ADDED=“티켓 댓글”(info — CS 영역의 정상 활동), HQ_TICKET_STATUS_CHANGED=“티켓 상태 변경”(info — #108, detail before→after), HQ_TICKET_PRIORITY_CHANGED=“티켓 우선순위 변경”(info — #108, detail before→after), HQ_STORE_UPDATED=“매장 정보 수정”(info — 정상 운영 활동), HQ_STORE_CREATED=“매장 등록”(info — 정상 운영 활동, #106), HQ_STORE_REGION_UPDATED=“매장 지역 변경”(info — #144, 정상 운영 활동). 전수 강제(Record<HqAuditItemAction, …>)라 후속 액션 추가 시 컴파일 에러로 누락 노출. 액션 select(hq-audit-filter-action)는 Object.values(HqAuditItemAction) 으로 자동 노출 — enum 확장 시 UI 도 자동 확장(코드 변경 0). apps/admin 운영자 본사 감사 뷰(/audit/hq)도 동일 15종 라벨/톤 매핑(audit-hq-client.tsx).
    • 대상: targetLabel(스냅샷 — 안내방송 제목 / CS 티켓 제목 / 매장명, targetType 에 따라)을 strong 으로. null 이면 . targetType 3종(TTS_ANNOUNCEMENT·SUPPORT_TICKET·STORE, SPEC #105).
    • 행위자: actorEmail(font-mono). actorRole === OPERATOR_IMPERSONATING 이면 보조 줄에 ↩ impersonatedByEmail + 운영자 위장 배지(hq-audit-impersonation-{id}, title="운영자가 본사로 위장해 송출한 감사 행"). 직접 송출(HQ_MANAGER)이면 보조 줄 미표시.
    • 상세: detail.trim()(없으면 ). 예: target=ALL·count=3·storeIds=[s1,s2,s3] (1024자 cap).
  • 빈 상태: total=0 + 필터 없음 → “아직 기록된 감사 로그가 없습니다.”(empty). 필터 있음 → “조건에 맞는 감사 로그가 없습니다.” + [필터 초기화]. total>0 인데 이 페이지가 0 행 → “이 페이지에 표시할 감사 로그가 없습니다.” + [첫 페이지로](page=0 reset).
  • 페이지네이션: 공용 ListPagination — 총 N건 + 이전/다음(경계 disabled). size=20. effectivePage = min(page+1, totalPages) 로 out-of-range 보호.
  • 에러 매핑: Banner tone="danger" (hq-audit-list-error):
    • 401 AUTH_UNAUTHENTICATED·NO_SESSION → “다시 로그인해주세요.” (mutator 가 /login 으로 풀 리로드).
    • 403 PRINCIPAL_SCOPE_MISMATCH·AUTH_FORBIDDEN·FORBIDDEN·ACCESS_DENIED → “이 페이지를 볼 권한이 없습니다.”
    • 5xx → “서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.”
    • BACKEND_UNREACHABLE (BFF 네트워크 도달 실패) → “서버에 연결할 수 없습니다.”

CSV 내보내기 (SPEC #070 — F5 ✅)

페이지 헤더 우측 [CSV 내보내기] 버튼(hq-audit-export-csv, lucide:Download 아이콘) — 현재 적용된 필터(from·to·action·actorAccountId·q) 의 전체 매칭 행을 단일 CSV 파일로 다운로드한다. 화면 페이지네이션(20) 과 무관 — 같은 필터 전체 집합을 한 파일로 받는다.

  • endpoint: GET /api/v1/hq/audit/dispatches/export (operationId exportHqAuditDispatches, text/csv; charset=utf-8, BOM + RFC4180). query 는 list 와 동일하되 page·size 만 제외.
  • 컬럼(한국어 헤더, BE-pure): 발생시각(KST)·액션·대상유형·대상라벨·대상ID·행위자 이메일·행위자 역할·임퍼소네이션 운영자 이메일·상세.
  • 파일명: Content-Disposition: attachment; filename="hq-audit-{yyyyMMdd-HHmmss}.csv"; filename*=UTF-8''... (RFC5987 filename* 우선 파싱·실패 시 filename·둘 다 실패하면 클라이언트가 KST 타임스탬프로 fallback 생성).
  • 상한·잘림 신호: backend EXPORT_MAX = 50,000 행. 초과 시 행 잘림 + 응답 헤더 X-Export-Truncated: true → FE 가 Banner tone="warn" (hq-audit-export-truncated) 으로 노출: “감사 행이 상한(50,000건)을 초과해 일부만 다운로드됐습니다. 필터를 좁힌 뒤 다시 시도해주세요.”
  • CSV injection 방어: =·+·-·@·탭·CR 시작 셀에 ' prefix (BE AuditCsvWriter, SPEC #048 미러).
  • 클라이언트 흐름 (SPEC #070 §D9): generated orval hook 우회. mutator(apiFetch) 는 모든 응답을 res.text() → JSON 파싱이라 CSV 부적합. 공용 helper @/lib/csv-export.ts downloadCsvFromBackend(path, query, fallbackFilename) — 브라우저에서 BFF (/api/backend/v1/hq/audit/dispatches/export?...) 로 직접 fetchres.blob()URL.createObjectURL<a download={filename}> (앵커 태그) 클릭 → URL.revokeObjectURL. 401 감지 시 /login?next={현재경로} 풀 리다이렉트(mutator 동작 미러). BFF catch-all proxy 는 arrayBuffer() passthrough + content-type/content-disposition 헤더 strip 안 함(SPEC #048 검증 그대로).
  • 버튼 상태: 클릭 → downloading=true → 라벨 “다운로드 중…” + aria-busy="true" + disabled. 성공·잘림 신호 노출·실패 모두 다운로드 종료 시 downloading=false 복귀.
  • 에러 매핑: Banner tone="danger" (hq-audit-export-error) — “CSV 다운로드에 실패했습니다. 잠시 후 다시 시도해주세요.” (401 은 풀 리다이렉트로 분기되므로 배너 미노출). [닫기] 버튼.

사이드바

HQSidebar“감사” 항목(/admin/audit)이 #067 에서 신규 enabled. icon History(lucide, 시계열 append-only 감사 메타포). 위치: LLM 자동멘트 아래, 설정 위 (handoff HQ_NAV 순서 보존 + 운영자 읽기 항목 가까이).

인가

/api/v1/hq/audit/**hasRole("HQ_MANAGER") 1차 경계 + service verifyHqScope claim↔DB 재검증(hqId 토큰 주체 도출, 요청 파라미터 없음). 미인증 401 · role/소속 불일치 403 PRINCIPAL_SCOPE_MISMATCH. 보안 불변식: search 쿼리에 WHERE hq_id = :hqId 강제 — impersonate 중인 운영자도 본사 토큰이라 같은 hqId 만 조회(자기가 한 액션 노출, 운영사 책임 추적 자연 폐쇄).

모든 호출은 generated apiFetch 가 BFF catch-all /api/backend/... 경유(토큰 서버 전용).

기록 hook (BE 요약)

기록은 HqAnnouncementDispatchService.dispatch 끝, 같은 트랜잭션 내 HqAuditService.record 1줄 (propagation REQUIRED 자연 합치). dispatch row INSERT 와 audit INSERT 가 한 트랜잭션 — audit 실패 시 전체 롤백(원자성, #026 패턴).

  • detail: target={ALL|STORES}·count=N·storeIds=[...앞 10개...] (1024자 cap, STORES 일 때만 storeIds).
  • 0건 송출: dispatch row 0건이어도 dispatch endpoint 호출 자체는 audit (count=0 으로 기록).
  • actor 해석: principal.accountId = HQ_MANAGER 계정. impersonatedBy != null 이면 actor_role=OPERATOR_IMPERSONATING + impersonated_by_operator_id/email. 둘 다 lookup 실패 시 IllegalStateException → 액션 롤백 (audit actor 미상으로 액션 진행 거부).

자세한 dispatch 백본·점장 ack: TTS Announcements 참조.

Followups

  • #067 송출 audit (백본 + 페이지): HQ_MANAGER actor 차원 추적 — 도착.
  • F1 이력 다이얼로그(#065) actor 컬럼 (#071): announcement_dispatch.audit_id V28 FK + DispatchHistoryItem.{actorEmail,actorRole,impersonatedByEmail} 3 필드 + DispatchHistoryDialog 5번째 “행위자” 컬럼(impersonate 보조 줄 + “운영자 위장” 배지) — 도착. 본 페이지의 audit 와 이력 다이얼로그의 actor 가 동일 hq_audit_log row 를 가리킨다. V28 이전 row 는 백필 안 함(actor 셀 ).
  • F2 announcement create/update/delete audit ✅ SPEC #068 도착 — HqAuditAction 4종 확장 완료(HQ_ANNOUNCEMENT_CREATED/UPDATED/DELETED 추가). 본 페이지의 액션 select 가 자동으로 늘어났음 (Object.values(HqAuditItemAction)).
  • #077 송출 취소 audit: HqAuditAction 5종으로 확장(HQ_ANNOUNCEMENT_DISPATCH_CANCELED 추가). 본사 행 inline [취소]가 같은 트랜잭션에 본사 audit 1건을 기록(audit INSERT 실패 시 함께 롤백 — 액션-감사 원자성). 본 페이지 액션 select·라벨 record(ACTION_LABELS·ACTION_TONES)도 자동 5종으로 확장.
  • #108 CS 티켓 상태/우선순위 변경 audit: HqAuditAction 11종으로 확장(HQ_TICKET_STATUS_CHANGED·HQ_TICKET_PRIORITY_CHANGED 추가). 본사 상세 처리 섹션의 상태/우선순위 변경이 같은 트랜잭션에 본사 audit 1건을 기록(detail before→after, 우선순위 동일값 멱등 시 생략). 본 페이지 액션 select·라벨 record 도 자동 11종으로 확장.
  • F3 점장 즉시방송 audit: store_audit_log 별도 백본(STORE_BROADCAST 액션 actor 추적).
  • F4 운영사 admin 통합 audit 화면: 본사·점장 audit 통합 조회(권한 모델 결정 후 별도 SPEC).
  • F5 CSV export (#070): exportHqAuditDispatches + 헤더 [CSV 내보내기] + 잘림 경고 — 도착.
  • F6 점장 ack 알림·실시간 audit: 송출→재생 사이클의 실시간 audit feed.
  • F7 audit 보존 정책: 90일·익명화 등 GDPR-friendly 보존 정책.
  • F8 audit 페이지 시안: workspace design_handoff_linkmusic/briefs/requests/hq-audit-page.md handoff 요청서 후속.
  • F9 actor select endpoint (#069): GET /api/v1/hq/audit/actors(listHqAuditActorsHqAuditActorListResponse{items}, 본사 audit 등장 actor distinct·occurrenceCount desc·email asc·최대 200). FE actorAccountId 자유 입력 → select 전환 + UUID_REGEX 제거. 같은 SPEC 에서 apps/space 인라인 formatKstDateTime 사본을 공용 @/lib/format helper 로 마이그레이션(정책 일관·24시간제, #066 §Followups 마감).