백업 현황을 사이트 용량으로 재정의하고 파일 집계를 이미지·영상·오디오·첨부 파일·보내기로 명확히 분리해 합계 불일치를 해소했다. 설정 화면의 통계 카드 스타일을 통일해 가독성을 높였다. Co-authored-by: Cursor <cursoragent@cursor.com>
146 lines
3.6 KiB
JavaScript
146 lines
3.6 KiB
JavaScript
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'
|
|
}
|
|
|
|
/**
|
|
* 업로드 파일을 분류 집계에 반영한다.
|
|
* @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 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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 업로드 디렉터리를 재귀적으로 스캔한다.
|
|
* @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()
|
|
}
|
|
}
|
|
|
|
await scanUploadDirectory(uploadRoot, summary)
|
|
|
|
return summary
|
|
}
|