import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { basename, dirname, extname, join, relative } from 'node:path' import { createZipBuffer } from '../utils/zip-writer' import { getRuntimeEnvValue } from '../utils/runtime-env' import { isResendConfigured, sendResendEmail } from '../utils/resend-mail' import { getDefaultSiteSettings } from '../utils/site-settings' import { getPostgresClient } from './postgres-client' const EXPORT_STATUS = { QUEUED: 'queued', PROCESSING: 'processing', FAILED: 'failed', READY: 'ready' } const EXPORT_FILE_STATUS = { PENDING: 'pending', PROCESSING: 'processing', READY: 'ready', FAILED: 'failed' } 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() const localUploadUrlPattern = /\/uploads\/[^\s"'<>)]*/g /** * 문자열을 HTML 텍스트로 안전하게 표시한다. * @param {unknown} value - 원본 값 * @returns {string} HTML escape 문자열 */ const escapeHtml = (value) => String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') /** * 파일명에 쓸 수 없는 문자를 정리한다. * @param {string} value - 원본 문자열 * @returns {string} 파일명 안전 문자열 */ const sanitizeFilenameSegment = (value) => String(value || '') .trim() .replace(/[\\/:*?"<>|]+/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 80) || 'sori.studio' /** * 중복되지 않는 ZIP 내부 경로 세그먼트를 만든다. * @param {string} value - 원본 세그먼트 * @param {Set} usedSegments - 이미 사용한 세그먼트 * @returns {string} 고유 세그먼트 */ const createUniqueSegment = (value, usedSegments) => { const base = sanitizeFilenameSegment(value) let next = base let index = 2 while (usedSegments.has(next)) { next = `${base}-${index}` index += 1 } usedSegments.add(next) return next } /** * ZIP 내부 파일명 중복을 방지한다. * @param {string} value - 원본 파일명 * @param {Set} usedNames - 이미 사용한 파일명 * @returns {string} 고유 파일명 */ const createUniqueAssetName = (value, usedNames) => { const rawName = sanitizeFilenameSegment(value || 'asset') const extension = extname(rawName) const stem = extension ? rawName.slice(0, -extension.length) : rawName let next = rawName let index = 2 while (usedNames.has(next)) { next = extension ? `${stem}-${index}${extension}` : `${stem}-${index}` index += 1 } usedNames.add(next) return next } /** * YAML 값에 안전한 따옴표를 적용한다. * @param {unknown} value - 원본 값 * @returns {string} YAML 문자열 값 */ const quoteYamlValue = (value) => `"${String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` /** * YAML 배열을 만든다. * @param {Array} values - 배열 값 * @returns {string} YAML 배열 */ const formatYamlArray = (values) => { if (!Array.isArray(values) || values.length === 0) { return '[]' } return `[${values.map((value) => quoteYamlValue(value)).join(', ')}]` } /** * 내부 업로드 URL인지 확인한다. * @param {string} value - URL * @returns {boolean} 내부 업로드 URL 여부 */ const isLocalUploadUrl = (value) => String(value || '').startsWith('/uploads/') /** * 이미지 확장자인지 확인한다. * @param {string} value - 파일명 또는 URL * @returns {boolean} 이미지 여부 */ const isImageAsset = (value) => /\.(avif|gif|jpe?g|png|svg|webp)$/i.test(String(value || '').split(/[?#]/)[0]) /** * 업로드 URL을 안전한 디스크 경로로 변환한다. * @param {string} url - 내부 업로드 URL * @returns {string|null} 디스크 경로 */ const resolveUploadUrlToDiskPath = (url) => { const pathOnly = String(url || '').split(/[?#]/)[0] if (!isLocalUploadUrl(pathOnly)) { return null } const relativeUploadPath = decodeURIComponent(pathOnly.replace(/^\/uploads\/?/, '')) const diskPath = join(UPLOAD_ROOT, relativeUploadPath) const safeRelativePath = relative(UPLOAD_ROOT, diskPath) if (!safeRelativePath || safeRelativePath.startsWith('..')) { return null } return diskPath } /** * 게시물 본문에서 내부 업로드 URL을 수집한다. * @param {string} content - 게시물 본문 * @returns {Array} 내부 업로드 URL 목록 */ const collectLocalUploadUrls = (content) => { const matches = String(content || '').match(localUploadUrlPattern) || [] 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 - 게시물 * @param {Object} assets - 변환된 자산 경로 * @returns {string} frontmatter 문자열 */ const createPostFrontmatter = (post, assets) => { const lines = [ '---', `id: ${quoteYamlValue(post.id)}`, `title: ${quoteYamlValue(post.title)}`, `slug: ${quoteYamlValue(post.slug)}`, `status: ${quoteYamlValue(post.status)}`, `published_at: ${post.published_at ? quoteYamlValue(post.published_at.toISOString()) : 'null'}`, `created_at: ${quoteYamlValue(post.created_at.toISOString())}`, `updated_at: ${quoteYamlValue(post.updated_at.toISOString())}`, `excerpt: ${quoteYamlValue(post.excerpt || '')}`, `featured_image: ${assets.featuredImage ? quoteYamlValue(assets.featuredImage) : 'null'}`, `seo_title: ${quoteYamlValue(post.seo_title || '')}`, `seo_description: ${quoteYamlValue(post.seo_description || '')}`, `canonical_url: ${quoteYamlValue(post.canonical_url || '')}`, `noindex: ${post.noindex ? 'true' : 'false'}`, `og_image: ${assets.ogImage ? quoteYamlValue(assets.ogImage) : 'null'}`, `tags: ${formatYamlArray(post.tags || [])}`, '---', '' ] return lines.join('\n') } /** * 게시물의 내부 업로드 자산을 ZIP 파일 목록으로 변환한다. * @param {Object} post - 게시물 * @param {string} postFolder - 게시물 폴더명 * @returns {Promise} 변환 결과 */ const buildPostAssetFiles = async (post, postFolder) => { 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) } const replacements = {} const zipFiles = [] const usedAssetNames = new Set() for (const url of urls) { const diskPath = resolveUploadUrlToDiskPath(url) if (!diskPath) { continue } try { const fileStat = await stat(diskPath) if (!fileStat.isFile()) { continue } const assetFolder = isImageAsset(url) ? 'images' : 'files' const fileName = createUniqueAssetName(basename(diskPath), usedAssetNames) const localPath = `./${assetFolder}/${fileName}` replacements[url] = localPath zipFiles.push({ path: `${postFolder}/${assetFolder}/${fileName}`, data: await readFile(diskPath) }) } catch { replacements[url] = url } } return { replacements, zipFiles } } /** * 게시물을 Obsidian 친화적인 Markdown 파일 묶음으로 변환한다. * @param {Object} post - 게시물 * @param {Set} usedFolderNames - 사용한 폴더명 * @returns {Promise>} ZIP 내부 파일 목록 */ const buildPostZipFiles = async (post, usedFolderNames) => { const postFolder = createUniqueSegment(post.title || post.slug || post.id, usedFolderNames) const { replacements, zipFiles } = await buildPostAssetFiles(post, postFolder) const replaceUrl = (value) => { let next = String(value || '') Object.entries(replacements).forEach(([source, target]) => { next = next.split(source).join(target) }) return next } const markdown = [ createPostFrontmatter(post, { featuredImage: replacements[post.featured_image] || post.featured_image || '', ogImage: replacements[post.og_image] || post.og_image || '' }), replaceUrl(post.content || '') ].join('\n') return [ { path: `${postFolder}/${sanitizeFilenameSegment(post.title || post.slug || 'post')}.md`, data: markdown }, ...zipFiles ] } /** * export 작업 행을 응답 구조로 변환한다. * @param {Object} row - DB 행 * @returns {Object} export 작업 */ const mapPostExportJobRow = (row) => ({ id: row.id, requestedBy: row.requested_by || null, requestedEmail: row.requested_email || '', status: row.status, scope: row.scope, postCount: Number(row.post_count || 0), 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, rangeLabel: row.range_label || '전체', 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, completedAt: row.completed_at ? row.completed_at.toISOString() : null, files: row.files || [] }) /** * export 분할 파일 행을 응답 구조로 변환한다. * @param {Object} row - DB 행 * @returns {Object} export 분할 파일 */ const mapPostExportFileRow = (row) => ({ id: row.id, jobId: row.job_id, partIndex: Number(row.part_index || 0), postStart: Number(row.post_start || 0), postEnd: Number(row.post_end || 0), fileName: row.file_name, filePath: row.file_path || '', fileSizeBytes: Number(row.file_size_bytes || 0), status: row.status, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), completedAt: row.completed_at ? row.completed_at.toISOString() : null }) /** * 요청된 분할 크기를 안전 범위로 보정한다. * @param {unknown} value - 입력 분할 크기 * @returns {number} 보정된 분할 크기 */ const normalizeChunkSize = (value) => { const parsed = Number(value) if (!Number.isFinite(parsed)) { return DEFAULT_CHUNK_SIZE } return Math.min(Math.max(Math.trunc(parsed), 1), MAX_CHUNK_SIZE) } /** * export 산출물 보존 일수를 안전 범위로 보정한다. * @param {unknown} value - 입력 보존 일수 * @returns {number} 보정된 보존 일수 */ const normalizeRetentionDays = (value) => { const parsed = Number(value) if (!Number.isFinite(parsed)) { return DEFAULT_RETENTION_DAYS } 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 - 날짜 입력 * @returns {Date|null} 보정된 날짜 */ const normalizeDateInput = (value) => { if (!value) { return null } const date = value instanceof Date ? value : new Date(String(value)) if (Number.isNaN(date.getTime())) { return null } return date } /** * 날짜 범위 라벨을 만든다. * @param {Date|null} dateFrom - 시작일 * @param {Date|null} dateTo - 종료일 * @returns {string} 범위 라벨 */ const createRangeLabel = (dateFrom, dateTo) => { const formatDate = (date) => date.toISOString().slice(0, 10) if (!dateFrom && !dateTo) { return '전체' } if (dateFrom && dateTo) { return `${formatDate(dateFrom)} - ${formatDate(new Date(dateTo.getTime() - 1))}` } if (dateFrom) { return `${formatDate(dateFrom)} 이후` } return `${formatDate(new Date(dateTo.getTime() - 1))} 이전` } /** * Export 날짜 범위를 보정한다. * @param {Object} input - 작업 요청 입력 * @returns {{ dateFrom: Date|null, dateTo: Date|null, rangeLabel: string }} 날짜 범위 */ const normalizeExportDateRange = (input) => { const dateFrom = normalizeDateInput(input.dateFrom) const dateTo = normalizeDateInput(input.dateTo) if (dateFrom && dateTo && dateFrom >= dateTo) { throw new Error('INVALID_EXPORT_DATE_RANGE') } return { dateFrom, dateTo, rangeLabel: input.rangeLabel || createRangeLabel(dateFrom, dateTo) } } /** * Export 날짜 범위 조건을 만든다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {Object} range - 날짜 범위 * @returns {unknown} SQL 조건 */ const createPostExportDateCondition = (sql, range) => { if (range.dateFrom && range.dateTo) { return sql`COALESCE(posts.published_at, posts.created_at) >= ${range.dateFrom} AND COALESCE(posts.published_at, posts.created_at) < ${range.dateTo}` } if (range.dateFrom) { return sql`COALESCE(posts.published_at, posts.created_at) >= ${range.dateFrom}` } if (range.dateTo) { return sql`COALESCE(posts.published_at, posts.created_at) < ${range.dateTo}` } return sql`true` } /** * Export 파일 경로를 안전하게 해석한다. * @param {string} filePath - 상대 경로 * @returns {string|null} 디스크 경로 */ const resolveExportFilePath = (filePath) => { const absolutePath = join(UPLOAD_ROOT, filePath || '') const safeRelativePath = relative(UPLOAD_ROOT, absolutePath) if (!safeRelativePath || safeRelativePath.startsWith('..')) { return null } return absolutePath } /** * 사이트 이름을 조회한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @returns {Promise} 사이트 이름 */ const getExportSiteName = async (sql) => { const rows = await sql` SELECT title FROM site_settings ORDER BY updated_at DESC LIMIT 1 ` return rows[0]?.title || getDefaultSiteSettings().title } /** * 게시물에 포함된 내부 업로드 파일 크기를 추산한다. * @param {Object} post - 게시물 * @returns {Promise} 내부 자산 바이트 합계 */ 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} 추산 바이트 */ 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} sql - PostgreSQL 클라이언트 * @param {Object} input - 조회 입력 * @returns {Promise} 계획 대상 게시물 목록 */ 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} posts - 순서가 확정된 게시물 목록 * @param {Object} input - 계획 옵션 * @returns {Promise>} 분할 계획 */ 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 작업 * @returns {Promise} */ const sendPostExportReadyEmail = async (job) => { if (!job?.requestedEmail || !isResendConfigured({})) { return } const siteTitle = await getExportSiteName(getPostgresClient()) const fileCount = Array.isArray(job.files) ? job.files.filter((file) => file.status === EXPORT_FILE_STATUS.READY).length : 0 const config = useRuntimeConfig() const adminUrl = getRuntimeEnvValue('SITE_URL', 'siteUrl').trim() || String(config.public?.siteUrl || '').trim() || '' const settingsUrl = adminUrl ? `${adminUrl.replace(/\/$/, '')}/admin/settings` : '/admin/settings' await sendResendEmail({ apiKey: getRuntimeEnvValue('RESEND_API_KEY', 'resendApiKey').trim(), from: getRuntimeEnvValue('RESEND_FROM_EMAIL', 'resendFromEmail').trim(), to: job.requestedEmail, subject: `[${siteTitle}] 게시물 Export 준비 완료`, html: [ '
', `

${escapeHtml(siteTitle)} 게시물 Export가 준비되었습니다.

`, `

범위: ${escapeHtml(job.rangeLabel || '전체')}

`, `

게시물 ${Number(job.postCount || 0).toLocaleString()}개, 분할 파일 ${fileCount.toLocaleString()}개가 생성되었습니다.

`, `

관리자 설정 화면에서 필요한 파일을 다운로드해 주세요. 산출물은 최대 ${Number(job.retentionDays || DEFAULT_RETENTION_DAYS)}일 동안 보관됩니다.

`, `

Export 다운로드 화면 열기

`, '
' ].join('') }) } /** * 만료된 Export 작업과 파일을 정리한다. * @returns {Promise} 삭제한 작업 수 */ export const cleanupExpiredPostExportJobs = async () => { const sql = getPostgresClient() if (!sql) { return 0 } const expiredJobs = await sql` SELECT * FROM post_export_jobs WHERE expires_at < now() AND status NOT IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING}) ORDER BY expires_at ASC LIMIT 20 ` if (expiredJobs.length === 0) { return 0 } const jobIds = expiredJobs.map((job) => job.id) const fileRows = await sql` SELECT * FROM post_export_files WHERE job_id = ANY(${jobIds}) ` for (const file of fileRows) { if (!file.file_path) { continue } const absolutePath = resolveExportFilePath(file.file_path) if (absolutePath) { await rm(absolutePath, { force: true }) } } await sql` DELETE FROM post_export_jobs WHERE id = ANY(${jobIds}) ` return jobIds.length } /** * export 작업 목록 조회 * @returns {Promise} export 작업 목록 */ export const listPostExportJobs = async () => { const sql = getPostgresClient() if (!sql) { return [] } await cleanupExpiredPostExportJobs() const jobRows = await sql` SELECT * FROM post_export_jobs ORDER BY created_at DESC LIMIT 20 ` if (jobRows.length === 0) { return [] } const jobIds = jobRows.map((row) => row.id) const fileRows = await sql` SELECT * FROM post_export_files WHERE job_id = ANY(${jobIds}) ORDER BY job_id, part_index ASC ` const filesByJobId = fileRows.reduce((groups, row) => { const key = row.job_id groups[key] = groups[key] || [] groups[key].push(mapPostExportFileRow(row)) return groups }, {}) return jobRows.map((row) => mapPostExportJobRow({ ...row, files: filesByJobId[row.id] || [] })) } /** * 실행 가능한 대기 작업 ID 목록 조회 * @returns {Promise>} 대기 작업 ID 목록 */ export const listQueuedPostExportJobIds = async () => { const sql = getPostgresClient() if (!sql) { return [] } const rows = await sql` SELECT id FROM post_export_jobs WHERE status = ${EXPORT_STATUS.QUEUED} ORDER BY created_at ASC LIMIT 3 ` return rows.map((row) => row.id) } /** * 게시물 export 작업 요청 생성 * @param {Object} input - 작업 요청 입력 * @param {string} input.requestedBy - 요청 관리자 회원 ID * @param {string} input.requestedEmail - 요청 관리자 이메일 * @param {'all'|'author'} [input.scope] - export 범위 * @param {Date|string|null} [input.dateFrom] - 시작일 * @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} 생성된 export 작업 */ export const createPostExportJob = async (input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } await cleanupExpiredPostExportJobs() 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)) const fileRangeName = sanitizeFilenameSegment(exportDateRange.rangeLabel) const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000) const [createdJob] = await sql.begin(async (transaction) => { 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 작업이 대기열에 등록되었습니다.' : '내보낼 게시물이 없습니다.' const jobRows = await transaction` INSERT INTO post_export_jobs ( requested_by, requested_email, status, scope, post_count, chunk_size, max_file_size_bytes, retention_days, date_from, date_to, range_label, expires_at, message, completed_at ) VALUES ( ${input.requestedBy}, ${input.requestedEmail || ''}, ${status}, ${scope}, ${postCount}, ${chunkSize}, ${maxFileSizeBytes}, ${retentionDays}, ${exportDateRange.dateFrom}, ${exportDateRange.dateTo}, ${exportDateRange.rangeLabel}, ${expiresAt}, ${message}, ${postCount > 0 ? null : new Date()} ) RETURNING * ` const job = jobRows[0] if (postCount > 0) { 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` INSERT INTO post_export_files ( job_id, part_index, post_start, post_end, file_name, status ) VALUES ( ${fileInput.jobId}, ${fileInput.partIndex}, ${fileInput.postStart}, ${fileInput.postEnd}, ${fileInput.fileName}, ${fileInput.status} ) ` } } return [job] }) return (await listPostExportJobs()).find((job) => job.id === createdJob.id) || mapPostExportJobRow({ ...createdJob, files: [] }) } /** * Export 작업 상세를 조회한다. * @param {string} jobId - 작업 ID * @returns {Promise} Export 작업 */ export const getPostExportJobById = async (jobId) => { const sql = getPostgresClient() if (!sql) { return null } const jobRows = await sql` SELECT * FROM post_export_jobs WHERE id = ${jobId} LIMIT 1 ` if (!jobRows[0]) { return null } const fileRows = await sql` SELECT * FROM post_export_files WHERE job_id = ${jobId} ORDER BY part_index ASC ` return mapPostExportJobRow({ ...jobRows[0], files: fileRows.map(mapPostExportFileRow) }) } /** * Export 작업을 처리 중 상태로 변경한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {string} jobId - 작업 ID * @returns {Promise} */ const markPostExportJobProcessing = async (sql, jobId) => { await sql` UPDATE post_export_jobs 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}) ` } /** * Export 작업 진행도를 저장한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {Object} input - 진행도 입력 * @returns {Promise} */ const updatePostExportJobProgress = async (sql, input) => { await sql` UPDATE post_export_jobs SET processed_count = ${input.processedCount}, current_part_index = ${input.currentPartIndex}, progress_message = ${input.progressMessage}, updated_at = now() WHERE id = ${input.jobId} ` } /** * Export 작업을 완료 상태로 변경한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {string} jobId - 작업 ID * @param {number} postCount - 전체 게시물 수 * @returns {Promise} */ const markPostExportJobReady = async (sql, jobId, postCount) => { await sql` UPDATE post_export_jobs SET status = ${EXPORT_STATUS.READY}, processed_count = ${postCount}, current_part_index = null, message = '게시물 Export 파일 생성이 완료되었습니다.', progress_message = '생성이 완료되었습니다.', error_detail = '', completed_at = now(), updated_at = now() WHERE id = ${jobId} ` } /** * Export 작업을 실패 상태로 변경한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {string} jobId - 작업 ID * @param {unknown} error - 오류 * @returns {Promise} */ 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() WHERE id = ${jobId} ` } /** * Export 분할 파일을 처리 중 상태로 변경한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {string} fileId - 파일 ID * @returns {Promise} */ const markPostExportFileProcessing = async (sql, fileId) => { await sql` UPDATE post_export_files SET status = ${EXPORT_FILE_STATUS.PROCESSING}, updated_at = now() WHERE id = ${fileId} ` } /** * Export 분할 파일을 완료 상태로 변경한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {Object} input - 파일 완료 입력 * @returns {Promise} */ const markPostExportFileReady = async (sql, input) => { await sql` UPDATE post_export_files SET status = ${EXPORT_FILE_STATUS.READY}, file_path = ${input.filePath}, file_size_bytes = ${input.fileSizeBytes}, completed_at = now(), updated_at = now() WHERE id = ${input.fileId} ` } /** * Export 분할 파일을 실패 상태로 변경한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {string} fileId - 파일 ID * @returns {Promise} */ const markPostExportFileFailed = async (sql, fileId) => { await sql` UPDATE post_export_files SET status = ${EXPORT_FILE_STATUS.FAILED}, updated_at = now() WHERE id = ${fileId} ` } /** * Export 분할 범위에 해당하는 게시물을 조회한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @param {Object} job - Export 작업 * @param {Object} file - Export 분할 파일 * @returns {Promise} 게시물 목록 */ const listPostsForExportFile = async (sql, job, file) => { const limit = Number(file.postEnd || 0) - Number(file.postStart || 0) + 1 const offset = Number(file.postStart || 1) - 1 const dateCondition = createPostExportDateCondition(sql, { dateFrom: job.dateFrom ? new Date(job.dateFrom) : null, dateTo: job.dateTo ? new Date(job.dateTo) : null }) if (job.scope === 'author') { return sql` SELECT posts.*, COALESCE(array_agg(tags.slug ORDER BY tags.sort_order ASC, tags.name ASC) FILTER (WHERE tags.id IS NOT NULL), '{}') AS tags FROM posts LEFT JOIN post_tags ON post_tags.post_id = posts.id LEFT JOIN tags ON tags.id = post_tags.tag_id WHERE posts.author_id = ${job.requestedBy} AND ${dateCondition} GROUP BY posts.id ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC LIMIT ${limit} OFFSET ${offset} ` } return sql` SELECT posts.*, COALESCE(array_agg(tags.slug ORDER BY tags.sort_order ASC, tags.name ASC) FILTER (WHERE tags.id IS NOT NULL), '{}') AS tags FROM posts LEFT JOIN post_tags ON post_tags.post_id = posts.id LEFT JOIN tags ON tags.id = post_tags.tag_id WHERE ${dateCondition} GROUP BY posts.id ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC LIMIT ${limit} OFFSET ${offset} ` } /** * Export ZIP 파일을 생성한다. * @param {Object} job - Export 작업 * @param {Object} file - Export 분할 파일 * @returns {Promise} 생성 결과 */ const createPostExportZipFile = async (job, file) => { const sql = getPostgresClient() const posts = await listPostsForExportFile(sql, job, file) const usedFolderNames = new Set() const zipEntries = [] for (const post of posts) { zipEntries.push(...await buildPostZipFiles(post, usedFolderNames)) } const zipBuffer = createZipBuffer(zipEntries) const now = new Date() const year = String(now.getFullYear()) const month = String(now.getMonth() + 1).padStart(2, '0') const relativePath = join(EXPORT_ROOT_NAME, year, month, job.id, file.fileName) const absolutePath = join(UPLOAD_ROOT, relativePath) await mkdir(dirname(absolutePath), { recursive: true }) await writeFile(absolutePath, zipBuffer) return { relativePath, fileSizeBytes: zipBuffer.length, postCount: posts.length } } /** * 게시물 Export 작업을 실제로 실행한다. * @param {string} jobId - Export 작업 ID * @returns {Promise} */ export const runPostExportJob = async (jobId) => { const sql = getPostgresClient() if (!sql || runningPostExportJobIds.has(jobId)) { return } runningPostExportJobIds.add(jobId) try { const job = await getPostExportJobById(jobId) if (!job || ![EXPORT_STATUS.QUEUED, EXPORT_STATUS.PROCESSING].includes(job.status)) { return } await markPostExportJobProcessing(sql, jobId) const readyPostCount = job.files .filter((file) => file.status === EXPORT_FILE_STATUS.READY) .reduce((total, file) => total + Math.max(file.postEnd - file.postStart + 1, 0), 0) let processedCount = Math.max(Number(job.processedCount || 0), readyPostCount) for (const file of job.files) { if (file.status === EXPORT_FILE_STATUS.READY) { continue } try { await markPostExportFileProcessing(sql, file.id) await updatePostExportJobProgress(sql, { jobId, processedCount, currentPartIndex: file.partIndex, progressMessage: `${file.partIndex}번째 분할 파일을 생성하는 중입니다.` }) const result = await createPostExportZipFile(job, file) processedCount += result.postCount await markPostExportFileReady(sql, { fileId: file.id, filePath: result.relativePath, fileSizeBytes: result.fileSizeBytes }) await updatePostExportJobProgress(sql, { jobId, processedCount, currentPartIndex: file.partIndex, progressMessage: `${file.fileName} 생성 완료 (${processedCount.toLocaleString()} / ${job.postCount.toLocaleString()})` }) } catch (error) { await markPostExportFileFailed(sql, file.id) throw error } } await markPostExportJobReady(sql, jobId, job.postCount) try { await sendPostExportReadyEmail({ ...job, files: (await getPostExportJobById(jobId))?.files || job.files }) } catch (error) { const message = error instanceof Error ? error.message : '이메일 알림을 보내지 못했습니다.' console.error('게시물 Export 완료 이메일 발송 실패', error) await updatePostExportJobProgress(sql, { jobId, processedCount: job.postCount, currentPartIndex: null, progressMessage: `생성이 완료되었습니다. 이메일 알림 실패: ${message}` }) } } catch (error) { await markPostExportJobFailed(sql, jobId, error) } finally { runningPostExportJobIds.delete(jobId) } } /** * Export 작업을 백그라운드 실행 대기열에 올린다. * @param {string} jobId - Export 작업 ID * @returns {void} */ export const queuePostExportJobRun = (jobId) => { if (!jobId || runningPostExportJobIds.has(jobId)) { return } setTimeout(() => { runPostExportJob(jobId).catch((error) => { console.error('게시물 Export 작업 실행 실패', error) }) }, 0) } /** * 대기 중인 Export 작업을 백그라운드 실행 대기열에 올린다. * @returns {Promise} */ export const queuePendingPostExportJobs = async () => { const jobIds = await listQueuedPostExportJobIds() jobIds.forEach(queuePostExportJobRun) } /** * 실패한 Export 작업을 준비 완료 파일 이후부터 다시 실행할 수 있게 대기 상태로 되돌린다. * @param {string} jobId - Export 작업 ID * @returns {Promise} 재시도 대상 작업 */ export const retryPostExportJob = async (jobId) => { const sql = getPostgresClient() if (!sql) { return null } const job = await getPostExportJobById(jobId) if (!job || job.status !== EXPORT_STATUS.FAILED) { return null } await sql.begin(async (transaction) => { await transaction` UPDATE post_export_files SET status = ${EXPORT_FILE_STATUS.PENDING}, updated_at = now() WHERE job_id = ${jobId} AND status <> ${EXPORT_FILE_STATUS.READY} ` await transaction` UPDATE post_export_jobs SET status = ${EXPORT_STATUS.QUEUED}, message = '실패한 Export 작업을 다시 대기열에 등록했습니다.', progress_message = '준비 완료 파일은 유지하고 실패 지점부터 다시 생성합니다.', error_detail = '', current_part_index = null, completed_at = null, updated_at = now() WHERE id = ${jobId} ` }) const retriedJob = await getPostExportJobById(jobId) queuePostExportJobRun(jobId) return retriedJob } /** * 다운로드 가능한 Export 파일을 조회한다. * @param {string} fileId - Export 파일 ID * @returns {Promise} Export 파일 */ export const getReadyPostExportFile = async (fileId) => { const sql = getPostgresClient() if (!sql) { return null } const rows = await sql` SELECT * FROM post_export_files WHERE id = ${fileId} AND status = ${EXPORT_FILE_STATUS.READY} AND file_path <> '' LIMIT 1 ` if (!rows[0]) { return null } return mapPostExportFileRow(rows[0]) } /** * 게시물 Export 작업과 생성 파일을 삭제한다. * @param {string} jobId - Export 작업 ID * @returns {Promise} 삭제 여부 */ export const deletePostExportJob = async (jobId) => { const sql = getPostgresClient() if (!sql) { return false } const job = await getPostExportJobById(jobId) if (!job) { return false } if ([EXPORT_STATUS.QUEUED, EXPORT_STATUS.PROCESSING].includes(job.status)) { return false } for (const file of job.files) { if (!file.filePath) { continue } const absolutePath = resolveExportFilePath(file.filePath) if (absolutePath) { await rm(absolutePath, { force: true }) } } await sql` DELETE FROM post_export_jobs WHERE id = ${jobId} ` return true }