게시물 Export 용량 기준 분할 추가 v1.5.25
This commit is contained in:
@@ -20,9 +20,12 @@ const EXPORT_FILE_STATUS = {
|
||||
FAILED: 'failed'
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 100
|
||||
const DEFAULT_CHUNK_SIZE = 500
|
||||
const MAX_CHUNK_SIZE = 500
|
||||
const DEFAULT_RETENTION_DAYS = 100
|
||||
const DEFAULT_MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024
|
||||
const MIN_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads')
|
||||
const EXPORT_ROOT_NAME = 'exports'
|
||||
const runningPostExportJobIds = new Set()
|
||||
@@ -162,6 +165,13 @@ const collectLocalUploadUrls = (content) => {
|
||||
return [...new Set(matches.filter(isLocalUploadUrl))]
|
||||
}
|
||||
|
||||
/**
|
||||
* UTF-8 바이트 길이를 계산한다.
|
||||
* @param {unknown} value - 원본 값
|
||||
* @returns {number} 바이트 길이
|
||||
*/
|
||||
const getUtf8ByteLength = (value) => Buffer.byteLength(String(value || ''), 'utf8')
|
||||
|
||||
/**
|
||||
* 게시물 frontmatter를 만든다.
|
||||
* @param {Object} post - 게시물
|
||||
@@ -296,6 +306,7 @@ const mapPostExportJobRow = (row) => ({
|
||||
processedCount: Number(row.processed_count || 0),
|
||||
currentPartIndex: row.current_part_index ? Number(row.current_part_index) : null,
|
||||
chunkSize: Number(row.chunk_size || DEFAULT_CHUNK_SIZE),
|
||||
maxFileSizeBytes: Number(row.max_file_size_bytes || DEFAULT_MAX_FILE_SIZE_BYTES),
|
||||
retentionDays: Number(row.retention_days || DEFAULT_RETENTION_DAYS),
|
||||
dateFrom: row.date_from ? row.date_from.toISOString() : null,
|
||||
dateTo: row.date_to ? row.date_to.toISOString() : null,
|
||||
@@ -303,6 +314,7 @@ const mapPostExportJobRow = (row) => ({
|
||||
expiresAt: row.expires_at ? row.expires_at.toISOString() : null,
|
||||
message: row.message || '',
|
||||
progressMessage: row.progress_message || '',
|
||||
errorDetail: row.error_detail || '',
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
startedAt: row.started_at ? row.started_at.toISOString() : null,
|
||||
@@ -360,6 +372,21 @@ const normalizeRetentionDays = (value) => {
|
||||
return Math.min(Math.max(Math.trunc(parsed), 1), DEFAULT_RETENTION_DAYS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 분할 ZIP 최대 용량을 안전 범위로 보정한다.
|
||||
* @param {unknown} value - 입력 바이트
|
||||
* @returns {number} 보정된 최대 용량
|
||||
*/
|
||||
const normalizeMaxFileSizeBytes = (value) => {
|
||||
const parsed = Number(value || getRuntimeEnvValue('POST_EXPORT_MAX_FILE_SIZE_BYTES', 'postExportMaxFileSizeBytes', DEFAULT_MAX_FILE_SIZE_BYTES))
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_MAX_FILE_SIZE_BYTES
|
||||
}
|
||||
|
||||
return Math.min(Math.max(Math.trunc(parsed), MIN_MAX_FILE_SIZE_BYTES), MAX_MAX_FILE_SIZE_BYTES)
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 입력을 안전하게 보정한다.
|
||||
* @param {unknown} value - 날짜 입력
|
||||
@@ -477,6 +504,161 @@ const getExportSiteName = async (sql) => {
|
||||
return rows[0]?.title || getDefaultSiteSettings().title
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물에 포함된 내부 업로드 파일 크기를 추산한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {Promise<number>} 내부 자산 바이트 합계
|
||||
*/
|
||||
const estimatePostAssetBytes = async (post) => {
|
||||
const urls = new Set(collectLocalUploadUrls(post.content))
|
||||
|
||||
if (isLocalUploadUrl(post.featured_image)) {
|
||||
urls.add(post.featured_image)
|
||||
}
|
||||
|
||||
if (isLocalUploadUrl(post.og_image)) {
|
||||
urls.add(post.og_image)
|
||||
}
|
||||
|
||||
let total = 0
|
||||
|
||||
for (const url of urls) {
|
||||
const diskPath = resolveUploadUrlToDiskPath(url)
|
||||
|
||||
if (!diskPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(diskPath)
|
||||
if (fileStat.isFile()) {
|
||||
total += fileStat.size
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 하나가 ZIP 안에서 차지할 대략적인 크기를 추산한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {Promise<number>} 추산 바이트
|
||||
*/
|
||||
const estimatePostExportBytes = async (post) => {
|
||||
const frontmatterReserve = 4096
|
||||
const zipEntryReserve = 2048
|
||||
const contentBytes = getUtf8ByteLength(post.content)
|
||||
const metaBytes = getUtf8ByteLength([
|
||||
post.title,
|
||||
post.slug,
|
||||
post.excerpt,
|
||||
post.seo_title,
|
||||
post.seo_description,
|
||||
post.canonical_url
|
||||
].join('\n'))
|
||||
const assetBytes = await estimatePostAssetBytes(post)
|
||||
|
||||
return Math.max(frontmatterReserve + zipEntryReserve + contentBytes + metaBytes + assetBytes, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 계획 대상 게시물을 순서대로 조회한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {Object} input - 조회 입력
|
||||
* @returns {Promise<Array>} 계획 대상 게시물 목록
|
||||
*/
|
||||
const listPostsForExportPlanning = async (sql, input) => {
|
||||
const dateCondition = createPostExportDateCondition(sql, input.exportDateRange)
|
||||
|
||||
if (input.scope === 'author') {
|
||||
return sql`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
excerpt,
|
||||
seo_title,
|
||||
seo_description,
|
||||
canonical_url,
|
||||
featured_image,
|
||||
og_image,
|
||||
COALESCE(published_at, created_at) AS export_date
|
||||
FROM posts
|
||||
WHERE author_id = ${input.requestedBy}
|
||||
AND ${dateCondition}
|
||||
ORDER BY COALESCE(published_at, created_at) ASC, id ASC
|
||||
`
|
||||
}
|
||||
|
||||
return sql`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
excerpt,
|
||||
seo_title,
|
||||
seo_description,
|
||||
canonical_url,
|
||||
featured_image,
|
||||
og_image,
|
||||
COALESCE(published_at, created_at) AS export_date
|
||||
FROM posts
|
||||
WHERE ${dateCondition}
|
||||
ORDER BY COALESCE(published_at, created_at) ASC, id ASC
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 수와 추산 용량을 함께 사용해 Export 분할 계획을 만든다.
|
||||
* @param {Array<Object>} posts - 순서가 확정된 게시물 목록
|
||||
* @param {Object} input - 계획 옵션
|
||||
* @returns {Promise<Array<{ partIndex: number, postStart: number, postEnd: number }>>} 분할 계획
|
||||
*/
|
||||
const createPostExportFilePlan = async (posts, input) => {
|
||||
const plan = []
|
||||
let currentStart = 1
|
||||
let currentEnd = 0
|
||||
let currentCount = 0
|
||||
let currentBytes = 0
|
||||
|
||||
for (let index = 0; index < posts.length; index += 1) {
|
||||
const postNumber = index + 1
|
||||
const postBytes = await estimatePostExportBytes(posts[index])
|
||||
const shouldStartNext = currentCount > 0 && (
|
||||
currentCount >= input.chunkSize
|
||||
|| currentBytes + postBytes > input.maxFileSizeBytes
|
||||
)
|
||||
|
||||
if (shouldStartNext) {
|
||||
plan.push({
|
||||
partIndex: plan.length + 1,
|
||||
postStart: currentStart,
|
||||
postEnd: currentEnd
|
||||
})
|
||||
currentStart = postNumber
|
||||
currentCount = 0
|
||||
currentBytes = 0
|
||||
}
|
||||
|
||||
currentEnd = postNumber
|
||||
currentCount += 1
|
||||
currentBytes += postBytes
|
||||
}
|
||||
|
||||
if (currentCount > 0) {
|
||||
plan.push({
|
||||
partIndex: plan.length + 1,
|
||||
postStart: currentStart,
|
||||
postEnd: currentEnd
|
||||
})
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 완료 안내 이메일을 보낸다.
|
||||
* @param {Object} job - Export 작업
|
||||
@@ -639,6 +821,7 @@ export const listQueuedPostExportJobIds = async () => {
|
||||
* @param {Date|string|null} [input.dateTo] - 종료일
|
||||
* @param {string} [input.rangeLabel] - 범위 라벨
|
||||
* @param {number} [input.chunkSize] - 분할당 게시물 수
|
||||
* @param {number} [input.maxFileSizeBytes] - 분할 ZIP 최대 추산 용량
|
||||
* @param {number} [input.retentionDays] - 산출물 보존 일수
|
||||
* @returns {Promise<Object>} 생성된 export 작업
|
||||
*/
|
||||
@@ -653,6 +836,7 @@ export const createPostExportJob = async (input) => {
|
||||
|
||||
const scope = input.scope === 'author' ? 'author' : 'all'
|
||||
const chunkSize = normalizeChunkSize(input.chunkSize)
|
||||
const maxFileSizeBytes = normalizeMaxFileSizeBytes(input.maxFileSizeBytes)
|
||||
const retentionDays = normalizeRetentionDays(input.retentionDays)
|
||||
const exportDateRange = normalizeExportDateRange(input)
|
||||
const siteName = sanitizeFilenameSegment(await getExportSiteName(sql))
|
||||
@@ -660,20 +844,18 @@ export const createPostExportJob = async (input) => {
|
||||
const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [createdJob] = await sql.begin(async (transaction) => {
|
||||
const dateCondition = createPostExportDateCondition(transaction, exportDateRange)
|
||||
const [{ count }] = scope === 'author'
|
||||
? await transaction`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM posts
|
||||
WHERE author_id = ${input.requestedBy}
|
||||
AND ${dateCondition}
|
||||
`
|
||||
: await transaction`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM posts
|
||||
WHERE ${dateCondition}
|
||||
`
|
||||
const postCount = Number(count || 0)
|
||||
const planningPosts = await listPostsForExportPlanning(transaction, {
|
||||
scope,
|
||||
requestedBy: input.requestedBy,
|
||||
exportDateRange
|
||||
})
|
||||
const postCount = planningPosts.length
|
||||
const filePlan = postCount > 0
|
||||
? await createPostExportFilePlan(planningPosts, {
|
||||
chunkSize,
|
||||
maxFileSizeBytes
|
||||
})
|
||||
: []
|
||||
const status = postCount > 0 ? EXPORT_STATUS.QUEUED : EXPORT_STATUS.READY
|
||||
const message = postCount > 0
|
||||
? 'Export 작업이 대기열에 등록되었습니다.'
|
||||
@@ -686,6 +868,7 @@ export const createPostExportJob = async (input) => {
|
||||
scope,
|
||||
post_count,
|
||||
chunk_size,
|
||||
max_file_size_bytes,
|
||||
retention_days,
|
||||
date_from,
|
||||
date_to,
|
||||
@@ -701,6 +884,7 @@ export const createPostExportJob = async (input) => {
|
||||
${scope},
|
||||
${postCount},
|
||||
${chunkSize},
|
||||
${maxFileSizeBytes},
|
||||
${retentionDays},
|
||||
${exportDateRange.dateFrom},
|
||||
${exportDateRange.dateTo},
|
||||
@@ -714,20 +898,14 @@ export const createPostExportJob = async (input) => {
|
||||
const job = jobRows[0]
|
||||
|
||||
if (postCount > 0) {
|
||||
const partCount = Math.ceil(postCount / chunkSize)
|
||||
const fileInputs = Array.from({ length: partCount }, (_, index) => {
|
||||
const postStart = index * chunkSize + 1
|
||||
const postEnd = Math.min((index + 1) * chunkSize, postCount)
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
partIndex: index + 1,
|
||||
postStart,
|
||||
postEnd,
|
||||
fileName: `${siteName}_${fileRangeName}_${postStart}-${postEnd}.zip`,
|
||||
status: EXPORT_FILE_STATUS.PENDING
|
||||
}
|
||||
})
|
||||
const fileInputs = filePlan.map((part) => ({
|
||||
jobId: job.id,
|
||||
partIndex: part.partIndex,
|
||||
postStart: part.postStart,
|
||||
postEnd: part.postEnd,
|
||||
fileName: `${siteName}_${fileRangeName}_${part.postStart}-${part.postEnd}.zip`,
|
||||
status: EXPORT_FILE_STATUS.PENDING
|
||||
}))
|
||||
|
||||
for (const fileInput of fileInputs) {
|
||||
await transaction`
|
||||
@@ -808,6 +986,7 @@ const markPostExportJobProcessing = async (sql, jobId) => {
|
||||
SET status = ${EXPORT_STATUS.PROCESSING},
|
||||
started_at = COALESCE(started_at, now()),
|
||||
progress_message = 'Export 파일 생성을 시작했습니다.',
|
||||
error_detail = '',
|
||||
updated_at = now()
|
||||
WHERE id = ${jobId}
|
||||
AND status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING})
|
||||
@@ -846,6 +1025,7 @@ const markPostExportJobReady = async (sql, jobId, postCount) => {
|
||||
current_part_index = null,
|
||||
message = '게시물 Export 파일 생성이 완료되었습니다.',
|
||||
progress_message = '생성이 완료되었습니다.',
|
||||
error_detail = '',
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = ${jobId}
|
||||
@@ -861,12 +1041,14 @@ const markPostExportJobReady = async (sql, jobId, postCount) => {
|
||||
*/
|
||||
const markPostExportJobFailed = async (sql, jobId, error) => {
|
||||
const message = error instanceof Error ? error.message : '알 수 없는 오류'
|
||||
const stack = error instanceof Error && error.stack ? error.stack : message
|
||||
|
||||
await sql`
|
||||
UPDATE post_export_jobs
|
||||
SET status = ${EXPORT_STATUS.FAILED},
|
||||
message = ${`게시물 Export 생성 실패: ${message}`},
|
||||
progress_message = ${message},
|
||||
error_detail = ${stack.slice(0, 8000)},
|
||||
current_part_index = null,
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
@@ -1144,6 +1326,7 @@ export const retryPostExportJob = async (jobId) => {
|
||||
SET status = ${EXPORT_STATUS.QUEUED},
|
||||
message = '실패한 Export 작업을 다시 대기열에 등록했습니다.',
|
||||
progress_message = '준비 완료 파일은 유지하고 실패 지점부터 다시 생성합니다.',
|
||||
error_detail = '',
|
||||
current_part_index = null,
|
||||
completed_at = null,
|
||||
updated_at = now()
|
||||
|
||||
@@ -13,6 +13,7 @@ const postExportJobInputSchema = z.object({
|
||||
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
chunkSize: z.number().int().min(1).max(500).optional(),
|
||||
maxFileSizeBytes: z.number().int().min(10485760).max(2147483648).optional(),
|
||||
retentionDays: z.number().int().min(1).max(100).optional()
|
||||
}).default({})
|
||||
|
||||
@@ -120,6 +121,7 @@ export default defineEventHandler(async (event) => {
|
||||
dateTo: dateRange.dateTo,
|
||||
rangeLabel: dateRange.rangeLabel,
|
||||
chunkSize: input.chunkSize,
|
||||
maxFileSizeBytes: input.maxFileSizeBytes,
|
||||
retentionDays: input.retentionDays
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user