Files
sori.studio/server/repositories/post-export-repository.js

899 lines
25 KiB
JavaScript

import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, join, relative } from 'node:path'
import { createZipBuffer } from '../utils/zip-writer'
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 = 100
const MAX_CHUNK_SIZE = 500
const DEFAULT_RETENTION_DAYS = 100
const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads')
const EXPORT_ROOT_NAME = 'exports'
const runningPostExportJobIds = new Set()
const localUploadUrlPattern = /\/uploads\/[^\s"'<>)]*/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<string>} 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<string>} 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<string>} 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<string>} 내부 업로드 URL 목록
*/
const collectLocalUploadUrls = (content) => {
const matches = String(content || '').match(localUploadUrlPattern) || []
return [...new Set(matches.filter(isLocalUploadUrl))]
}
/**
* 게시물 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<Object>} 변환 결과
*/
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<string>} usedFolderNames - 사용한 폴더명
* @returns {Promise<Array<{path: string, data: Buffer|string}>>} 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),
retentionDays: Number(row.retention_days || DEFAULT_RETENTION_DAYS),
expiresAt: row.expires_at ? row.expires_at.toISOString() : null,
message: row.message || '',
progressMessage: row.progress_message || '',
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)
}
/**
* 사이트 이름을 조회한다.
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
* @returns {Promise<string>} 사이트 이름
*/
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
}
/**
* export 작업 목록 조회
* @returns {Promise<Array>} export 작업 목록
*/
export const listPostExportJobs = async () => {
const sql = getPostgresClient()
if (!sql) {
return []
}
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<Array<string>>} 대기 작업 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 {number} [input.chunkSize] - 분할당 게시물 수
* @param {number} [input.retentionDays] - 산출물 보존 일수
* @returns {Promise<Object>} 생성된 export 작업
*/
export const createPostExportJob = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const scope = input.scope === 'author' ? 'author' : 'all'
const chunkSize = normalizeChunkSize(input.chunkSize)
const retentionDays = normalizeRetentionDays(input.retentionDays)
const siteName = sanitizeFilenameSegment(await getExportSiteName(sql))
const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000)
const [createdJob] = await sql.begin(async (transaction) => {
const [{ count }] = scope === 'author'
? await transaction`
SELECT COUNT(*)::int AS count
FROM posts
WHERE author_id = ${input.requestedBy}
`
: await transaction`
SELECT COUNT(*)::int AS count
FROM posts
`
const postCount = Number(count || 0)
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,
retention_days,
expires_at,
message,
completed_at
)
VALUES (
${input.requestedBy},
${input.requestedEmail || ''},
${status},
${scope},
${postCount},
${chunkSize},
${retentionDays},
${expiresAt},
${message},
${postCount > 0 ? null : new Date()}
)
RETURNING *
`
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}_${postStart}-${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<Object|null>} 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<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {string} jobId - 작업 ID
* @returns {Promise<void>}
*/
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 파일 생성을 시작했습니다.',
updated_at = now()
WHERE id = ${jobId}
AND status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING})
`
}
/**
* Export 작업 진행도를 저장한다.
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {Object} input - 진행도 입력
* @returns {Promise<void>}
*/
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<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {string} jobId - 작업 ID
* @param {number} postCount - 전체 게시물 수
* @returns {Promise<void>}
*/
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 = '생성이 완료되었습니다.',
completed_at = now(),
updated_at = now()
WHERE id = ${jobId}
`
}
/**
* Export 작업을 실패 상태로 변경한다.
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {string} jobId - 작업 ID
* @param {unknown} error - 오류
* @returns {Promise<void>}
*/
const markPostExportJobFailed = async (sql, jobId, error) => {
const message = error instanceof Error ? error.message : '알 수 없는 오류'
await sql`
UPDATE post_export_jobs
SET status = ${EXPORT_STATUS.FAILED},
message = ${`게시물 Export 생성 실패: ${message}`},
progress_message = ${message},
current_part_index = null,
completed_at = now(),
updated_at = now()
WHERE id = ${jobId}
`
}
/**
* Export 분할 파일을 처리 중 상태로 변경한다.
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {string} fileId - 파일 ID
* @returns {Promise<void>}
*/
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<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {Object} input - 파일 완료 입력
* @returns {Promise<void>}
*/
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<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {string} fileId - 파일 ID
* @returns {Promise<void>}
*/
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<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {Object} job - Export 작업
* @param {Object} file - Export 분할 파일
* @returns {Promise<Array>} 게시물 목록
*/
const listPostsForExportFile = async (sql, job, file) => {
const limit = Number(file.postEnd || 0) - Number(file.postStart || 0) + 1
const offset = Number(file.postStart || 1) - 1
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}
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
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<Object>} 생성 결과
*/
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<void>}
*/
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)
} 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<void>}
*/
export const queuePendingPostExportJobs = async () => {
const jobIds = await listQueuedPostExportJobIds()
jobIds.forEach(queuePostExportJobRun)
}
/**
* 다운로드 가능한 Export 파일을 조회한다.
* @param {string} fileId - Export 파일 ID
* @returns {Promise<Object|null>} 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])
}