사이트 설정 백업 현황 추가
This commit is contained in:
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