FeaturesAudit (감사 로그)Operator Action Audit (/audit/actions)

Operator Action Audit — /audit/actions (운영자 액션 감사 로그)

SPEC #026 정합 · 시각 SPEC #031-D. 공통 OperatorAuditLog (append-only). 시안 출처: design_handoff_linkmusic/design/screens/ops-audit-actions.jsx (“운영자 액션” 탭 뷰).

Overview

운영자(OPERATOR)가 수행하는 핵심 변경 액션(본사 정지/복구·점장 발급·약관/개인정보 게시· 본사 온보딩)을 append-only 공통 감사 로그로 기록·조회하는 백오피스 화면. “누가 (actorEmail)·언제(occurredAt)·무엇을(action)·어느 대상에(targetType + targetLabel)” 를 추적한다 — 내부 통제·사후 조사용. 임퍼소네이션 세션 감사(#025)와는 별도 테이블이며 감사 영역 상단 탭으로 전환한다. 편집/삭제 불가(append-only).

시안 정합 (SPEC #031-D): ops-audit-actions.jsx 시안의 “운영자 액션” 탭 뷰를 반영한다. 테이블 컬럼은 시안 순서(발생 시각 KST · 액션 · 대상 유형 · 대상 · 행위자 · 상세)이며, 액션 배지는 시안 actionMeta 톤을 @linkmusic/ui StatusPill 톤에 매핑한다(아래 표). 필터·빈상태· 페이지네이션·KST 경계 정규화는 SPEC #025 /audit/impersonation 와 동일 server-driven 관용구.

기록 액션 (action enum)

시안 actionMeta 톤 → @linkmusic/ui StatusPill 톤 매핑(시안 brand 는 primary 계열 playing, 시안 neutralmuted). 색만으로 구분하지 않도록 항상 한글 라벨을 병기한다.

action한글 라벨배지 톤계기 (BE service)대상 targetTypedetail 예
HQ_SUSPENDED본사 정지dangerHqStatusService.suspend (#018/#024)HQ정지 사유(reason)
HQ_REACTIVATED본사 복구successHqStatusService.reactivateHQ
HQ_UPDATED본사명 편집infoHqAdminService.updateHq (#123)HQname:before->after 스냅샷
STORE_SUSPENDED매장 정지dangerStoreStatusService.suspend (#037)STORE정지 사유(reason)
STORE_REACTIVATED매장 복구successStoreStatusService.reactivate (#037)STORE
STORE_MANAGER_ISSUED점장 발급infoStoreManagerService.issue (#021)STORE
STORE_MANAGER_PASSWORD_RESET점장 비밀번호 재설정mutedStoreManagerService.resetPassword (#029)STORE
STORE_MANAGER_SUSPENDED점장 정지warnStoreManagerService.suspend (#029)STORE정지 사유
STORE_MANAGER_REACTIVATED점장 복구successStoreManagerService.reactivate (#029)STORE
STORE_MANAGER_REVOKED점장 회수dangerStoreManagerService.revoke (#029)STORE회수 사유
TERMS_PUBLISHED약관 게시playingTermsDocumentService.publish (#023)TERMSversion
PRIVACY_POLICY_PUBLISHED개인정보처리방침 게시playingPrivacyPolicyService.publish (#023)PRIVACY_POLICYversion
TERMS_SCHEDULE_CANCELED약관 예약 취소warnTermsDocumentService.cancelScheduled (#038)TERMSversion
PRIVACY_POLICY_SCHEDULE_CANCELED개인정보 예약 취소warnPrivacyPolicyService.cancelScheduled (#038)PRIVACY_POLICYversion
MUSIC_UPLOADED음원 업로드infoMusicUploadService.upload (#041)MUSIC원본 파일명·크기
MUSIC_FILE_REPLACED음원 파일 교체warnMusicUploadService.replaceFile (#041)MUSIC원본 파일명·크기
MUSIC_DELETED음원 소프트삭제dangerMusicDeleteService.softDelete (#042)MUSIC— (blob 유지·90일 보관)
TICKET_STATUS_CHANGED티켓 상태 변경infoTicketService.changeStatus (#047)TICKET상태 이전→이후 (예 OPEN→IN_PROGRESS)
TICKET_ASSIGNED티켓 담당자 배정infoTicketService.assign (#047)TICKET배정: {email} / 해제 (단일 액션, detail 로 분기)
TICKET_PRIORITY_CHANGED티켓 우선순위 변경warnTicketService.changePriority (#047)TICKET우선순위 이전→이후 (예 HIGH→URGENT)
HQ_ONBOARDED본사 온보딩playingHqOnboardingService.register (#011)HQ
  • 각 계기 service 의 액션 성공 지점에서 같은 트랜잭션 내 OperatorAuditService.record(...) 호출(액션과 감사의 원자성 — 기록 실패 시 액션 롤백). actor 는 SecurityContext 의 현재 운영자.
  • append-only — 수정/삭제 endpoint 가 없다. 향후 도메인 도착 시 action enum 확장.

대상 (targetType enum)

HQ(본사) · STORE(매장) · TERMS(약관) · PRIVACY_POLICY(개인정보처리방침) · MUSIC(음원, #041) · TICKET(CS 티켓, #047). targetId(UUID)로 식별하고 targetLabel(hqName·storeName·약관 version·곡 제목·티켓 title 등 스냅샷)로 표시한다.

UI

  • <PageHeader> 제목 “감사 로그 · 운영자 액션” + 부제 “총 N건”.
  • 감사 영역 탭 — 상단에 <AuditTabs>(임퍼소네이션 | 운영자 액션) 라우트 탭. 사이드바 “감사 로그” 항목은 단일 항목(/audit/impersonation)으로 유지하고 두 audit 페이지가 탭을 공유한다. 탭은 next/link 기반(라우트 전환 — @linkmusic/ui Tab atom 은 client 필터 토글용 button group 이라 부적합), 활성 표시는 aria-current="page".
  • 단일 테이블 (시안 컬럼 순서) — 발생 시각(KST occurredAt) · 액션(한글 라벨 톤 배지) · 대상 유형(targetType 라벨) · 대상(targetLabel strong, 없으면 ) · 행위자(<OrgAvatar> + actorEmail) · 상세(detail, 없으면 ). 한 행에 정보가 모두 표시되며 드릴다운(행 상세) 없음 → 행 선택/a11y row 불필요.
  • 필터 바 — 공용 @linkmusic/ui ListToolbar(controlled)로 통합(temp-layout Phase 4 · 4목록 공용 컴포넌트 통합 완료 — 운영자·매장·본사에 이어 감사까지). 검색(q, ListToolbar 내장 search) + 발생 기간 from/to(kind:"date" <input type=date> — 이 슬라이스에서 ListToolbarFilter 에 추가된 date 타입) + 액션·대상·행위자 select. 단 감사는 URL-driven(운영자/매장/본사의 client-query 와 의도적 분기) — state·URL 은 호출부(searchParams + buildQuery/goToPage)가 보유하고 ListToolbar 에는 값/onChange 만 위임한다. dirty(draft↔URL 차이) 시 [적용] 강조 + “미적용 변경” 힌트. rightSlot 은 비워둠(#048 CSV 내보내기 BE 후속). 적용 시 router.push 로 URL 갱신 → server component 재fetch (backend 가 필터·정렬·페이지네이션 책임). 1페이지로 리셋. [초기화]는 q·actorOperatorId 포함 전 필터를 클리어.
    • 행위자 select (SPEC #034) — 옵션 = “전체”(기본) + 운영자 목록(email 라벨, value=id). 옵션은 GET /api/v1/admin/operators(#032) status 무관 전체(회수·정지된 운영자도 과거 감사 행위자일 수 있음)에서 page.tsx 가 병렬 fetch 해 {id,email}[] 로 내려준다. URL 진입값이 옵션에 없으면 “전체”로 폴백(옵션과 항상 일치).
    • 검색 input (SPEC #034) — free-text q(max 100). 매칭 범위는 backend 의 targetLabel + detail 부분일치(ILIKE, 대소문자 무시). actorEmail 은 행위자 select 와 역할 중복이라 검색 범위 제외. trim 후 빈 값은 무관.
  • 페이지네이션 — 공용 @linkmusic/ui ListPagination(controlled)로 통합. page/size/total. [이전]/[다음] 버튼이 URL page(1-based) 갱신 → backend 0-based 변환.
  • 빈 상태total === 0 진짜 0건(“기록된 운영자 액션이 없습니다”) vs total > 0 && items 0 out-of-range page(“이 페이지에 표시할 액션이 없습니다” + [첫 페이지로]) 별도.

시각 표시 (KST)

occurredAt(backend UTC)은 Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Seoul", ... }) 로 KST clock time(2026-05-17 22:58:12)으로 표시한다. timeZone 명시로 SSR/CSR 가 머신 TZ 와 무관 하게 동일 결과(문자열 slice 미사용 — 오프셋 무시 오표시 방지). 기간 필터 from/to<input type=date> date-only → backend(date-time) 호출 시 KST(+09:00) 경계 정규화(from=일 시작 00:00:00.000·to=일 끝 23:59:59.999, inclusive). (#025 교훈 선반영.)

Implementation

Page (server component, refresh-aware)

apps/admin/src/app/(protected)/audit/actions/page.tsxGET /api/v1/admin/audit/actions 을 server-side 로 fetch(보호 endpoint, OPERATOR-only — 토큰 서버 전용). /audit/impersonation 와 동일 refresh-aware 패턴: refreshIfNeeded → fetch → 401 catch → forceRefresh 1회 재시도 → 그래도 실패 시 session.destroy() + /login redirect. 5xx/네트워크는 강제 로그아웃하지 않고 errorMessage prop 으로 client banner.

  • searchParams(from·to·action·targetType·actorOperatorId·q·page)는 Next 15 Promise + 다중값 정규화(첫 element 채택). page 1-based URL → backend 0-based. action/targetType 은 generated enum 화이트리스트(아니면 무시). q 는 trim·max 100 가드. from/to KST 경계 정규화.
  • 행위자 옵션 (SPEC #034) — 감사 목록과 함께 GET /api/v1/admin/operators(#032)를 병렬 fetch(같은 refresh-aware 세션)해 {id,email}[] 로 client 에 prop 전달. remountKey 에 q·actorOperatorId 포함.
  • 응답 타입은 generated OperatorAuditListResponse/ListOperatorActionAuditParams(q 포함) (@linkmusic/api-client) 단일 소스 — 수기 중복 정의 없음. server helper backendListOperatorActions/backendListOperators(lib/backend.ts)가 정의된 param 만 직렬화.

Client

audit-actions-client.tsx — 필터 입력은 URL 초기값에서 hydrate(enum 화이트리스트·date-only 정규화). 필터/페이지는 client 재필터 없이 URL push → server refetch. action/targetType 한글 라벨은 Record<enum, string> 전수 매핑(새 액션 추가 시 컴파일 에러로 누락 노출). URL 단일 소스 정합을 위해 server component 가 searchParams 기반 key 로 client 를 remount.

audit-tabs.tsx(공유) — 두 audit 페이지 상단에 라우트 탭. 사이드바 href(/audit/impersonation) 변경 없음.

States & Edge Cases

상태처리
액션 0건”기록된 운영자 액션이 없습니다” 빈 상태 (total === 0)
out-of-range page”이 페이지에 표시할 액션이 없습니다” + [첫 페이지로] (total > 0, items 0)
5xx / 네트워크 실패client banner (세션 유지, 강제 로그아웃 X)
401 (refresh 실패)session.destroy() + /login redirect
detail 없음상세 셀
잘못된 action/targetType query무시(필터 미적용 — 화이트리스트)
q 길이 초과(>100)client maxLength=100 + server slice 가드 → backend 400 회피
URL actorOperatorId 가 운영자 목록에 없음행위자 select “전체”로 폴백(옵션과 일치)

CSV 내보내기 (SPEC #048)

운영자 액션 감사 로그를 현재 적용된 필터 전체 매칭 행으로 CSV 다운로드한다. 화면은 페이지네이션(20) 이지만 CSV 는 같은 필터의 전체 집합을 한 파일로 — 컴플라이언스·사후조사·외부보고 실수요. 시안 ops-audit-actions 의 PDF/CSV 액션 부재 후속을 베타에서 CSV 로 닫는다(PDF 는 별도, 우선순위 낮음).

Endpoint

GET /api/v1/admin/audit/actions/export (exportOperatorActions, OPERATOR-only) — 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename="audit-actions-{yyyyMMdd-HHmmss}.csv"; filename*=UTF-8''...(RFC5987 병기·한글 파일명 대응) + X-Export-Truncated: true|false 헤더(상한 신호). query 는 listOperatorActionAudit 와 동일하되 page/size 제외 — from?·to? ISO-8601 date-time · action? · targetType? · actorOperatorId? · q? (≤100). 정렬 occurred_at DESC, id DESC 서버 고정.

컬럼 (한국어 헤더, BE-pure 코드값)

#헤더비고
1발생시각(KST)Asia/Seoul 변환 (yyyy-MM-dd HH:mm:ss)
2액션enum 코드값(예 HQ_SUSPENDED) — FE ACTION_LABELS 로직 중복 회피, BE-pure
3대상유형enum 코드값(예 HQ/STORE/OPERATOR)
4대상라벨hqName·storeName·약관 version·곡 제목·티켓 title 등 스냅샷
5대상IDUUID, 없으면 빈 셀
6행위자actorEmail
7상세detail (null/blank → 빈 셀)

상한 · CSV injection 방어

  • 상한 EXPORT_MAX=50000 행(베타 규모 충분). 초과 시 잘림 + 응답 헤더 X-Export-Truncated: true → 클라이언트가 잘림 경고 배너 노출 (“감사 행이 상한(50,000건)을 초과해 일부만 다운로드됐습니다.”).
  • CSV injection 방어 — 공용 AuditCsvWriter=·+·-·@·탭·CR 시작 셀에 ' prefix(SPEC #070 본사 audit export 와 동일 모듈 재사용). detail·targetLabel 은 입력 스냅샷이라 필수 방어.

FE 다운로드 패턴 (generated 훅 우회)

generated orval hook 미사용apiFetch mutator 가 모든 응답을 res.text() → JSON 파싱이라 text/csv 응답에 부적합. 공용 helper apps/admin/src/lib/csv-export.ts downloadCsvFromBackend(path, query, fallbackFilename) 가 우회한다.

  1. 브라우저에서 BFF (/api/backend/v1/admin/audit/actions/export?...) 로 직접 fetch (catch-all route handler 가 arrayBuffer() passthrough + Content-Type/Content-Disposition 헤더 보존).
  2. res.blob()URL.createObjectURL<a download> 부착·클릭·제거 → 다음 tick 에 revokeObjectURL (Safari 다운로드 보호).
  3. 401 감지 시 /login?next={pathname+search} 풀 리다이렉트(mutator 동작 미러, 필터 search 보존).

/audit/actions PageHeader.primary 슬롯에 [CSV 내보내기] 버튼(lucide:Download 아이콘, secondary variant, sm). 진행 중 disabled + aria-busy="true" + 라벨 “다운로드 중…”. 잘림 시 Banner variant="warn"

  • [닫기], 실패 시 Banner variant="danger" + [닫기]AuditTabs 와 본문 사이에 배치.

보낼 필터 (applied 값)

화면 테이블이 보여주는 것과 일치하도록 draft(폼 입력) 가 아닌 URL 의 applied 필터를 보낸다. <input type=date> date-only 는 KST(+09:00) 경계로 정규화한다(from=00:00:00.000·to=23:59:59.999 inclusive — page.tsx normalizeBoundary 와 동일 규칙).

Roadmap (후속)

  • 다른 도메인 액션(정산 등) — 도메인 도착 시 action enum 확장. 음원 업로드/교체(MUSIC_UPLOADED·MUSIC_FILE_REPLACED)는 #041, 소프트삭제(MUSIC_DELETED)는 #042 로, CS 티켓 상태전이·담당자 배정/해제·우선순위 변경(TICKET_STATUS_CHANGED·TICKET_ASSIGNED·TICKET_PRIORITY_CHANGED)은 #047 로 도착.
  • 액션 detail 의 풍부한 diff(전/후 값) — 현재 핵심 메타·사유만.
  • 행위자 자동완성/검색형 콤보박스 — 현재 단순 select(전체 운영자). 운영자 수 증가 시(SPEC #034 followup).
  • q 검색 가속(pg_trgm GIN) — 베타 규모 seq scan 허용, 데이터 증가 시 followup(SPEC #034 비목표).
  • CSV 내보내기 ✅ (SPEC #048). PDF 내보내기 · 보존 정책 (#025 와 공통 후속). 임퍼소네이션(#025) CSV export 는 동일 패턴 후속.

References

  • SPEC #026 · SPEC #034 (검색 q · 행위자 select) · SPEC #048 (CSV 내보내기) · 시각 SPEC #031-D · #025 (임퍼소네이션 감사 — server-driven 관용구) · #032 (운영자 목록 — 행위자 옵션) · #018/#024·#021·#023·#029·#011·#041/#042·#047 (계기 액션) · #070 (본사 audit CSV — 공용 helper 패턴 원본)
  • linkmusic-frontend-space/apps/admin/src/app/(protected)/audit/actions/ (page.tsx · audit-actions-client.tsx) · audit/audit-tabs.tsx · apps/admin/src/lib/csv-export.ts
  • 시안 출처: workspace parent dir design_handoff_linkmusic/design/screens/ops-audit-actions.jsx