사이트 설정 백업 현황 추가
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.48에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.51에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-07-06 v1.10.51 — 백업 현황은 분석 통계와 분리해 가볍게 집계한다
|
||||
|
||||
사이트 설정에서 필요한 현황은 방문자·유입 분석이 아니라 백업과 서버 용량 점검 기준이다. 분석 테이블이나 관리자 미디어 사용처 계산을 재사용하면 불필요하게 무겁고, 미디어 전체 사용처 판정까지 동반되어 설정 화면 진입 비용이 커질 수 있다.
|
||||
|
||||
따라서 백업 현황 API는 콘텐츠 수량은 PostgreSQL의 단순 COUNT로, 파일 용량은 `public/uploads` 디렉터리 스캔으로만 집계한다. 업로드 파일은 원본 미디어, 자동 생성 이미지, 프로필 이미지, 내보내기 파일로 나누어 보여 주고, 운영자가 용량 원인을 빠르게 찾을 수 있도록 큰 파일 상위 목록만 함께 제공한다.
|
||||
|
||||
## 2026-07-06 v1.10.48 — 이동 오버레이는 로딩 표시가 아니라 화면 전환 막으로 동작해야 한다
|
||||
|
||||
게시물 상세를 블로킹 조회로 되돌려도 운영 환경에서는 로딩 스피너 뒤로 홈 헤더 이미지가 다시 보인 뒤 상세로 넘어가는 현상이 남았다. 원인은 로딩 오버레이가 반투명이라 이전 화면이 비쳐 보일 수 있고, `page:loading:end`가 새 화면의 실제 페인트보다 빠르게 발생할 수 있다는 점이었다.
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
|
||||
| lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 |
|
||||
| lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·레거시 JSON 문자열 복구·공개 노출 목록 생성 |
|
||||
| server/utils/backup-storage-summary.js | 관리자 사이트 설정 백업 현황용 업로드 파일 스캔·원본/자동 생성 이미지/프로필/내보내기 파일 용량 집계 |
|
||||
|
||||
## 문서
|
||||
|
||||
@@ -205,7 +206,7 @@
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명 읽기 행, **사이트 정보**(로고 미등록 점선 박스·URL·저작권 읽기 행), **SNS 정보**(아이콘 프리셋+주소 목록형 관리), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 진행 중 요청 버튼 잠금 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명 읽기 행, **사이트 정보**(로고 미등록 점선 박스·URL·저작권 읽기 행), **SNS 정보**(아이콘 프리셋+주소 목록형 관리), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **백업 현황** 텍스트 카드(콘텐츠 수량·미디어 용량·자동 생성 이미지·내보내기 파일·큰 파일), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 진행 중 요청 버튼 잠금 |
|
||||
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
@@ -304,6 +305,7 @@
|
||||
| server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API |
|
||||
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
|
||||
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| server/routes/admin/api/settings/backup-summary.get.js | 관리자 사이트 설정 백업 현황 API(콘텐츠 수량 DB 집계, `public/uploads` 파일 용량 스캔) |
|
||||
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-YYYYMM-*.webp`, `/uploads/system/favicon-YYYYMM-*.png` 생성, `시스템` 미디어 메타 저장, DB 반영 없이 URL 반환) |
|
||||
| server/routes/admin/api/settings/home-cover.post.js | 관리자 메인 화면 커버 업로드 API(`/uploads/system/home-cover-YYYYMM-*.webp` 생성, `시스템` 미디어 메타 저장, 라이트·다크 슬롯용 URL 반환) |
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
### 사이트 광고 슬롯
|
||||
|
||||
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostSidebarCode`, `adPostTopCode`, `adPostInArticleCode`, `adPostBottomCode` 일곱 위치의 HTML 코드를 저장한다. `adPostSidebarCode`는 게시물 상세 오른쪽 사이드바의 TOC 아래 광고로 사용한다.
|
||||
- 관리자 사이트 설정의 백업 현황 카드는 방문·유입 분석이 아니라 백업과 서버 용량 점검용 텍스트 요약이다. 게시글·페이지·태그·댓글·회원 수, 업로드 미디어 개수·용량, 원본·자동 생성 이미지·프로필 이미지·내보내기 파일 용량, 큰 파일 상위 5개를 표시한다.
|
||||
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
|
||||
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
|
||||
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
|
||||
@@ -674,6 +675,7 @@ components/content/
|
||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, `postTagLimit`, 업로드 제한, 라이트·다크 홈 커버, 어나운스 바, 사이트 코드 필드 포함)
|
||||
- `GET /admin/api/settings/backup-summary` - 관리자 백업 현황 조회. 콘텐츠 수량은 PostgreSQL에서 집계하고, 업로드 파일 용량은 `public/uploads`를 스캔해 원본 미디어·자동 생성 이미지(`thumbs`)·프로필 이미지·내보내기 파일로 분리한다.
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, `postTagLimit`, 업로드 제한, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames`, `adsTxt`, `customHeadCode`, `customFooterCode` 포함). `postTagLimit`은 1~10 정수만 허용한다. 업로드 최대 파일 수는 1~50, 업로드 전체 용량은 1MB~1GB 정수만 허용한다. `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
|
||||
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
|
||||
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 사이트 정보 저장 시 `PUT`으로 처리한다.
|
||||
@@ -839,7 +841,7 @@ components/content/
|
||||
|
||||
### 사이트 설정
|
||||
|
||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 게시물 백업 도구는 `게시물 내보내기`와 `게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **사이트 코드** 카드는 ads.txt, 공통 헤더 코드, 공통 푸터 코드의 등록 여부를 라벨/값 세로 행으로 보여 주고, `편집`을 눌렀을 때만 textarea와 저장/취소가 나타난다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 라벨/값 세로 행으로 읽기 전용 표시하고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리하며, 읽기 화면의 로고는 등록 상태 문구 없이 이미지 또는 점선 미등록 박스로 표시한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 콘텐츠·안전 영역의 **백업 현황**은 그래프 없이 텍스트 카드로 표시하고, 기준 시각·콘텐츠 수량·미디어 용량·큰 파일을 새로고침할 수 있다. 게시물 백업 도구는 `게시물 내보내기`와 `게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **사이트 코드** 카드는 ads.txt, 공통 헤더 코드, 공통 푸터 코드의 등록 여부를 라벨/값 세로 행으로 보여 주고, `편집`을 눌렀을 때만 textarea와 저장/취소가 나타난다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 라벨/값 세로 행으로 읽기 전용 표시하고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리하며, 읽기 화면의 로고는 등록 상태 문구 없이 이미지 또는 점선 미등록 박스로 표시한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
||||
- 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 ZIP을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 `/uploads/posts/YYYY/MM/`에 새 파일로 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 `slug-2`, `slug-3`처럼 새 슬러그로 가져온다. ZIP 안에서 자산을 찾지 못한 경우 Import는 계속 진행하되 응답 경고로 누락 경로를 알려 준다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다.
|
||||
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 체크박스로 선택한 뒤 전체 선택 또는 선택 파일 다운로드로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.51
|
||||
|
||||
- 관리자 사이트 설정에 백업 현황 카드 추가.
|
||||
- `/admin/api/settings/backup-summary` 추가: 게시글·페이지·태그·댓글·회원 수와 업로드 미디어·자동 생성 이미지·내보내기 파일 용량 집계.
|
||||
- 백업 현황에서 큰 업로드 파일 상위 5개를 텍스트 목록으로 표시.
|
||||
|
||||
## v1.10.50
|
||||
|
||||
- 게시물 상세를 `layouts/post` 대신 `layouts/default`로 통합해 홈↔게시물 이동 시 레이아웃 전체 교체(깜빡임 원인) 제거.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.48",
|
||||
"version": "1.10.51",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.48",
|
||||
"version": "1.10.51",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.50",
|
||||
"version": "1.10.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -193,6 +193,13 @@ const {
|
||||
} = await useFetch('/admin/api/posts/export-jobs', {
|
||||
default: () => []
|
||||
})
|
||||
const {
|
||||
data: backupSummary,
|
||||
pending: backupSummaryPending,
|
||||
refresh: refreshBackupSummary
|
||||
} = await useFetch('/admin/api/settings/backup-summary', {
|
||||
default: () => null
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
title: settings.value?.title || 'sori.studio',
|
||||
@@ -516,6 +523,149 @@ const formatExportFileSize = (value) => {
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업 현황 숫자를 쉼표 포함 문자열로 변환한다.
|
||||
* @param {number} value - 숫자 값
|
||||
* @returns {string} 숫자 라벨
|
||||
*/
|
||||
const formatBackupNumber = (value) => Number(value || 0).toLocaleString()
|
||||
|
||||
/**
|
||||
* 백업 현황 용량을 읽기 쉬운 문자열로 변환한다.
|
||||
* @param {number} value - 바이트 값
|
||||
* @returns {string} 용량 라벨
|
||||
*/
|
||||
const formatBackupSize = (value) => {
|
||||
const bytes = Number(value || 0)
|
||||
|
||||
if (!bytes) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업 현황 기준 시각을 표시한다.
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} 날짜 라벨
|
||||
*/
|
||||
const formatBackupGeneratedAt = (value) => {
|
||||
if (!value) {
|
||||
return '집계 대기'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업 현황 콘텐츠 지표 목록
|
||||
* @returns {Array<{ label: string, value: string, helper: string }>} 콘텐츠 지표
|
||||
*/
|
||||
const backupContentMetrics = computed(() => {
|
||||
const content = backupSummary.value?.content || {}
|
||||
const posts = content.posts || {}
|
||||
const pages = content.pages || {}
|
||||
const tags = content.tags || {}
|
||||
const comments = content.comments || {}
|
||||
const members = content.members || {}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '게시글',
|
||||
value: `${formatBackupNumber(posts.total)}개`,
|
||||
helper: `공개 ${formatBackupNumber(posts.published)} · 비공개 ${formatBackupNumber(posts.private)} · 초안 ${formatBackupNumber(posts.draft)} · 회원 ${formatBackupNumber(posts.members)}`
|
||||
},
|
||||
{
|
||||
label: '페이지',
|
||||
value: `${formatBackupNumber(pages.total)}개`,
|
||||
helper: `공개 ${formatBackupNumber(pages.published)} · 목록 ${formatBackupNumber(pages.listed)} · 추천 ${formatBackupNumber(pages.featured)}`
|
||||
},
|
||||
{
|
||||
label: '태그',
|
||||
value: `${formatBackupNumber(tags.total)}개`,
|
||||
helper: `사용 중 ${formatBackupNumber(tags.used)} · 관리 ${formatBackupNumber(tags.managed)} · 일반 ${formatBackupNumber(tags.general)}`
|
||||
},
|
||||
{
|
||||
label: '댓글',
|
||||
value: `${formatBackupNumber(comments.total)}개`,
|
||||
helper: `공개 ${formatBackupNumber(comments.published)} · 대기 ${formatBackupNumber(comments.pending)} · 차단 ${formatBackupNumber(comments.blocked)}`
|
||||
},
|
||||
{
|
||||
label: '회원',
|
||||
value: `${formatBackupNumber(members.total)}명`,
|
||||
helper: `소유자 ${formatBackupNumber(members.owner)} · 관리자 ${formatBackupNumber(members.admin)} · 회원 ${formatBackupNumber(members.member)}`
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 백업 현황 저장소 지표 목록
|
||||
* @returns {Array<{ label: string, value: string, helper: string }>} 저장소 지표
|
||||
*/
|
||||
const backupStorageMetrics = computed(() => {
|
||||
const storage = backupSummary.value?.storage || {}
|
||||
const total = storage.total || {}
|
||||
const originals = storage.originals || {}
|
||||
const derivatives = storage.derivatives || {}
|
||||
const profiles = storage.profiles || {}
|
||||
const exports = storage.exports || {}
|
||||
const byKind = storage.byKind || {}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '업로드 미디어',
|
||||
value: `${formatBackupNumber(total.count)}개`,
|
||||
helper: `누적 ${formatBackupSize(total.sizeBytes)}`
|
||||
},
|
||||
{
|
||||
label: '원본 미디어',
|
||||
value: `${formatBackupNumber(originals.count)}개`,
|
||||
helper: formatBackupSize(originals.sizeBytes)
|
||||
},
|
||||
{
|
||||
label: '자동 생성 이미지',
|
||||
value: `${formatBackupNumber(derivatives.count)}개`,
|
||||
helper: formatBackupSize(derivatives.sizeBytes)
|
||||
},
|
||||
{
|
||||
label: '프로필 이미지',
|
||||
value: `${formatBackupNumber(profiles.count)}개`,
|
||||
helper: formatBackupSize(profiles.sizeBytes)
|
||||
},
|
||||
{
|
||||
label: '내보내기 파일',
|
||||
value: `${formatBackupNumber(exports.count)}개`,
|
||||
helper: formatBackupSize(exports.sizeBytes)
|
||||
},
|
||||
{
|
||||
label: '이미지·영상',
|
||||
value: `${formatBackupNumber(byKind.images?.count)} / ${formatBackupNumber(byKind.videos?.count)}개`,
|
||||
helper: `이미지 ${formatBackupSize(byKind.images?.sizeBytes)} · 영상 ${formatBackupSize(byKind.videos?.sizeBytes)}`
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 백업 현황 큰 파일 목록
|
||||
* @returns {Array<Object>} 큰 파일 목록
|
||||
*/
|
||||
const backupLargestFiles = computed(() => Array.isArray(backupSummary.value?.storage?.largestFiles)
|
||||
? backupSummary.value.storage.largestFiles
|
||||
: [])
|
||||
|
||||
/**
|
||||
* export 작업 진행률을 계산한다.
|
||||
* @param {Object} job - export 작업
|
||||
@@ -617,6 +767,7 @@ const settingsNavGroups = [
|
||||
{
|
||||
heading: '콘텐츠·안전',
|
||||
items: [
|
||||
{ id: 'admin-settings-section-backup-summary', label: '백업 현황', keywords: 'backup storage media count volume 용량 통계', iconId: 'post-export' },
|
||||
{ id: 'admin-settings-section-export', label: '게시물 내보내기', keywords: 'export backup 내보내기', iconId: 'post-export' },
|
||||
{ id: 'admin-settings-section-import', label: '게시물 가져오기', keywords: 'import restore 가져오기', iconId: 'post-import' },
|
||||
{ id: 'admin-settings-section-spam', label: '스팸 필터', keywords: 'spam moderation comments', iconId: 'spam' }
|
||||
@@ -3314,6 +3465,101 @@ onBeforeUnmount(() => {
|
||||
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
|
||||
콘텐츠·안전
|
||||
</h2>
|
||||
<section
|
||||
id="admin-settings-section-backup-summary"
|
||||
class="admin-settings-screen__card admin-settings-screen__card--backup-summary rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div class="admin-settings-screen__card-head flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-[#15171a]">
|
||||
백업 현황
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
콘텐츠 수량과 업로드 용량을 기준으로 현재 백업 규모를 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f4f6f8] disabled:cursor-wait disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="backupSummaryPending"
|
||||
@click="refreshBackupSummary"
|
||||
>
|
||||
{{ backupSummaryPending ? '집계 중' : '새로고침' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2 text-xs text-[#657080]">
|
||||
<span class="rounded-full bg-[#f4f6f8] px-3 py-1">
|
||||
기준 {{ formatBackupGeneratedAt(backupSummary?.generatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3">
|
||||
<h3 class="text-sm font-bold text-[#15171a]">
|
||||
콘텐츠
|
||||
</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="metric in backupContentMetrics"
|
||||
:key="`backup-content-${metric.label}`"
|
||||
class="admin-settings-screen__backup-metric rounded-lg border border-[#edf0f2] bg-[#fbfcfd] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-sm font-semibold text-[#657080]">{{ metric.label }}</span>
|
||||
<strong class="text-sm font-bold text-[#15171a]">{{ metric.value }}</strong>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-relaxed text-[#657080]">
|
||||
{{ metric.helper }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 border-t border-[#eceff2] pt-5">
|
||||
<h3 class="text-sm font-bold text-[#15171a]">
|
||||
파일·용량
|
||||
</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="metric in backupStorageMetrics"
|
||||
:key="`backup-storage-${metric.label}`"
|
||||
class="admin-settings-screen__backup-metric rounded-lg border border-[#edf0f2] bg-[#fbfcfd] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-sm font-semibold text-[#657080]">{{ metric.label }}</span>
|
||||
<strong class="text-sm font-bold text-[#15171a]">{{ metric.value }}</strong>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-relaxed text-[#657080]">
|
||||
{{ metric.helper }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="backupLargestFiles.length"
|
||||
class="mt-6 grid gap-3 border-t border-[#eceff2] pt-5"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[#15171a]">
|
||||
큰 파일
|
||||
</h3>
|
||||
<div class="grid gap-2">
|
||||
<div
|
||||
v-for="file in backupLargestFiles"
|
||||
:key="file.url"
|
||||
class="admin-settings-screen__backup-file grid gap-1 rounded-lg border border-[#edf0f2] px-4 py-3 text-sm md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-4"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold text-[#15171a]">
|
||||
{{ file.name }}
|
||||
</span>
|
||||
<span class="font-mono text-xs font-semibold text-[#657080]">
|
||||
{{ formatBackupSize(file.sizeBytes) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="admin-settings-section-export"
|
||||
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
|
||||
149
server/routes/admin/api/settings/backup-summary.get.js
Normal file
149
server/routes/admin/api/settings/backup-summary.get.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getBackupStorageSummary } from '../../../../utils/backup-storage-summary'
|
||||
import { getPostgresClient } from '../../../../repositories/postgres-client'
|
||||
|
||||
/**
|
||||
* 빈 콘텐츠 집계를 만든다.
|
||||
* @returns {Object} 콘텐츠 집계
|
||||
*/
|
||||
const createEmptyContentSummary = () => ({
|
||||
posts: {
|
||||
total: 0,
|
||||
published: 0,
|
||||
draft: 0,
|
||||
private: 0,
|
||||
members: 0,
|
||||
scheduled: 0
|
||||
},
|
||||
pages: {
|
||||
total: 0,
|
||||
published: 0,
|
||||
draft: 0,
|
||||
private: 0,
|
||||
listed: 0,
|
||||
featured: 0
|
||||
},
|
||||
tags: {
|
||||
total: 0,
|
||||
managed: 0,
|
||||
general: 0,
|
||||
used: 0
|
||||
},
|
||||
comments: {
|
||||
total: 0,
|
||||
published: 0,
|
||||
pending: 0,
|
||||
blocked: 0
|
||||
},
|
||||
members: {
|
||||
total: 0,
|
||||
owner: 0,
|
||||
admin: 0,
|
||||
member: 0
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 숫자 값을 안전하게 정수로 변환한다.
|
||||
* @param {unknown} value - 원본 값
|
||||
* @returns {number} 정수 값
|
||||
*/
|
||||
const toCount = (value) => Number(value || 0)
|
||||
|
||||
/**
|
||||
* 백업 점검용 콘텐츠 수량 집계를 조회한다.
|
||||
* @returns {Promise<Object>} 콘텐츠 수량 집계
|
||||
*/
|
||||
const getBackupContentSummary = async () => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return createEmptyContentSummary()
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM posts) AS "postsTotal",
|
||||
(SELECT COUNT(*)::int FROM posts WHERE status = 'published') AS "postsPublished",
|
||||
(SELECT COUNT(*)::int FROM posts WHERE status = 'draft') AS "postsDraft",
|
||||
(SELECT COUNT(*)::int FROM posts WHERE status = 'private') AS "postsPrivate",
|
||||
(SELECT COUNT(*)::int FROM posts WHERE status = 'members') AS "postsMembers",
|
||||
(SELECT COUNT(*)::int FROM posts WHERE status IN ('published', 'members') AND published_at > now()) AS "postsScheduled",
|
||||
(SELECT COUNT(*)::int FROM pages) AS "pagesTotal",
|
||||
(SELECT COUNT(*)::int FROM pages WHERE status = 'published') AS "pagesPublished",
|
||||
(SELECT COUNT(*)::int FROM pages WHERE status = 'draft') AS "pagesDraft",
|
||||
(SELECT COUNT(*)::int FROM pages WHERE status = 'private') AS "pagesPrivate",
|
||||
(SELECT COUNT(*)::int FROM pages WHERE is_listed = true) AS "pagesListed",
|
||||
(SELECT COUNT(*)::int FROM pages WHERE is_featured = true) AS "pagesFeatured",
|
||||
(SELECT COUNT(*)::int FROM tags) AS "tagsTotal",
|
||||
(SELECT COUNT(*)::int FROM tags WHERE tag_type = 'managed') AS "tagsManaged",
|
||||
(SELECT COUNT(*)::int FROM tags WHERE tag_type = 'general') AS "tagsGeneral",
|
||||
(SELECT COUNT(DISTINCT tag_id)::int FROM post_tags) AS "tagsUsed",
|
||||
(SELECT COUNT(*)::int FROM comments) AS "commentsTotal",
|
||||
(SELECT COUNT(*)::int FROM comments WHERE status = 'published') AS "commentsPublished",
|
||||
(SELECT COUNT(*)::int FROM comments WHERE status = 'pending') AS "commentsPending",
|
||||
(SELECT COUNT(*)::int FROM comments WHERE status = 'blocked') AS "commentsBlocked",
|
||||
(SELECT COUNT(*)::int FROM users) AS "membersTotal",
|
||||
(SELECT COUNT(*)::int FROM users WHERE user_role = 'owner') AS "membersOwner",
|
||||
(SELECT COUNT(*)::int FROM users WHERE user_role = 'admin') AS "membersAdmin",
|
||||
(SELECT COUNT(*)::int FROM users WHERE user_role = 'member') AS "membersMember"
|
||||
`
|
||||
const row = rows[0] || {}
|
||||
|
||||
return {
|
||||
posts: {
|
||||
total: toCount(row.postsTotal),
|
||||
published: toCount(row.postsPublished),
|
||||
draft: toCount(row.postsDraft),
|
||||
private: toCount(row.postsPrivate),
|
||||
members: toCount(row.postsMembers),
|
||||
scheduled: toCount(row.postsScheduled)
|
||||
},
|
||||
pages: {
|
||||
total: toCount(row.pagesTotal),
|
||||
published: toCount(row.pagesPublished),
|
||||
draft: toCount(row.pagesDraft),
|
||||
private: toCount(row.pagesPrivate),
|
||||
listed: toCount(row.pagesListed),
|
||||
featured: toCount(row.pagesFeatured)
|
||||
},
|
||||
tags: {
|
||||
total: toCount(row.tagsTotal),
|
||||
managed: toCount(row.tagsManaged),
|
||||
general: toCount(row.tagsGeneral),
|
||||
used: toCount(row.tagsUsed)
|
||||
},
|
||||
comments: {
|
||||
total: toCount(row.commentsTotal),
|
||||
published: toCount(row.commentsPublished),
|
||||
pending: toCount(row.commentsPending),
|
||||
blocked: toCount(row.commentsBlocked)
|
||||
},
|
||||
members: {
|
||||
total: toCount(row.membersTotal),
|
||||
owner: toCount(row.membersOwner),
|
||||
admin: toCount(row.membersAdmin),
|
||||
member: toCount(row.membersMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 백업 현황 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 백업 점검용 현황
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const [content, storage] = await Promise.all([
|
||||
getBackupContentSummary(),
|
||||
getBackupStorageSummary()
|
||||
])
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
content,
|
||||
storage
|
||||
}
|
||||
})
|
||||
176
server/utils/backup-storage-summary.js
Normal file
176
server/utils/backup-storage-summary.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { extname, join, relative, sep } from 'node:path'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
const imageExtensions = new Set(['.avif', '.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'])
|
||||
const videoExtensions = new Set(['.mp4', '.mov', '.webm'])
|
||||
const audioExtensions = new Set(['.m4a', '.mp3', '.ogg', '.wav'])
|
||||
|
||||
/**
|
||||
* 빈 파일 분류 집계를 만든다.
|
||||
* @returns {{ count: number, sizeBytes: number }} 파일 집계
|
||||
*/
|
||||
const createFileBucket = () => ({
|
||||
count: 0,
|
||||
sizeBytes: 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 파일 집계를 증가시킨다.
|
||||
* @param {{ count: number, sizeBytes: number }} bucket - 집계 대상
|
||||
* @param {number} sizeBytes - 파일 크기
|
||||
* @returns {void}
|
||||
*/
|
||||
const addFileBucket = (bucket, sizeBytes) => {
|
||||
bucket.count += 1
|
||||
bucket.sizeBytes += sizeBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장자 기준 파일 종류를 판단한다.
|
||||
* @param {string} filename - 파일명
|
||||
* @returns {'images'|'videos'|'audio'|'files'} 파일 종류
|
||||
*/
|
||||
const getFileKind = (filename) => {
|
||||
const extension = extname(filename).toLowerCase()
|
||||
|
||||
if (imageExtensions.has(extension)) {
|
||||
return 'images'
|
||||
}
|
||||
|
||||
if (videoExtensions.has(extension)) {
|
||||
return 'videos'
|
||||
}
|
||||
|
||||
if (audioExtensions.has(extension)) {
|
||||
return 'audio'
|
||||
}
|
||||
|
||||
return 'files'
|
||||
}
|
||||
|
||||
/**
|
||||
* 업로드 상대 경로를 공개 URL로 변환한다.
|
||||
* @param {string} relativePath - 업로드 루트 기준 상대 경로
|
||||
* @returns {string} 공개 URL
|
||||
*/
|
||||
const toUploadUrl = (relativePath) => `/uploads/${relativePath.split(sep).map(encodeURIComponent).join('/')}`
|
||||
|
||||
/**
|
||||
* 큰 파일 목록에 파일을 추가하고 상위 5개만 유지한다.
|
||||
* @param {Array<Object>} largestFiles - 큰 파일 목록
|
||||
* @param {Object} file - 파일 정보
|
||||
* @returns {void}
|
||||
*/
|
||||
const addLargestFile = (largestFiles, file) => {
|
||||
largestFiles.push(file)
|
||||
largestFiles.sort((a, b) => b.sizeBytes - a.sizeBytes)
|
||||
|
||||
if (largestFiles.length > 5) {
|
||||
largestFiles.length = 5
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 업로드 파일을 분류 집계에 반영한다.
|
||||
* @param {Object} summary - 업로드 집계
|
||||
* @param {string} relativePath - 업로드 루트 기준 상대 경로
|
||||
* @param {number} sizeBytes - 파일 크기
|
||||
* @returns {void}
|
||||
*/
|
||||
const addUploadFile = (summary, relativePath, sizeBytes) => {
|
||||
const segments = relativePath.split(sep)
|
||||
const firstSegment = segments[0] || ''
|
||||
const publicUrl = toUploadUrl(relativePath)
|
||||
const kind = getFileKind(relativePath)
|
||||
|
||||
if (firstSegment === 'exports') {
|
||||
addFileBucket(summary.exports, sizeBytes)
|
||||
return
|
||||
}
|
||||
|
||||
addFileBucket(summary.total, sizeBytes)
|
||||
addFileBucket(summary.byKind[kind], sizeBytes)
|
||||
|
||||
if (segments.includes('thumbs')) {
|
||||
addFileBucket(summary.derivatives, sizeBytes)
|
||||
} else if (firstSegment === 'members') {
|
||||
addFileBucket(summary.profiles, sizeBytes)
|
||||
} else {
|
||||
addFileBucket(summary.originals, sizeBytes)
|
||||
}
|
||||
|
||||
addLargestFile(summary.largestFiles, {
|
||||
name: segments.at(-1) || relativePath,
|
||||
url: publicUrl,
|
||||
kind,
|
||||
sizeBytes
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 업로드 디렉터리를 재귀적으로 스캔한다.
|
||||
* @param {string} directory - 현재 디렉터리
|
||||
* @param {Object} summary - 업로드 집계
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const scanUploadDirectory = async (directory, summary) => {
|
||||
let entries = []
|
||||
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = join(directory, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await scanUploadDirectory(absolutePath, summary)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fileStat = await stat(absolutePath)
|
||||
const relativePath = relative(uploadRoot, absolutePath)
|
||||
|
||||
if (!relativePath || relativePath.startsWith('..')) {
|
||||
continue
|
||||
}
|
||||
|
||||
addUploadFile(summary, relativePath, fileStat.size)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업 점검용 업로드 파일 집계를 조회한다.
|
||||
* @returns {Promise<Object>} 업로드 파일 집계
|
||||
*/
|
||||
export const getBackupStorageSummary = async () => {
|
||||
const summary = {
|
||||
total: createFileBucket(),
|
||||
originals: createFileBucket(),
|
||||
derivatives: createFileBucket(),
|
||||
profiles: createFileBucket(),
|
||||
exports: createFileBucket(),
|
||||
byKind: {
|
||||
images: createFileBucket(),
|
||||
videos: createFileBucket(),
|
||||
audio: createFileBucket(),
|
||||
files: createFileBucket()
|
||||
},
|
||||
largestFiles: []
|
||||
}
|
||||
|
||||
await scanUploadDirectory(uploadRoot, summary)
|
||||
|
||||
return summary
|
||||
}
|
||||
Reference in New Issue
Block a user