사이트 설정 백업 현황 추가

This commit is contained in:
2026-07-06 16:32:37 +09:00
parent 4c8f6e44f6
commit 8c84d2536c
10 changed files with 593 additions and 6 deletions

View 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
}
})

View 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
}