1411 lines
40 KiB
JavaScript
1411 lines
40 KiB
JavaScript
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, '"')
|
|
.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<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))]
|
|
}
|
|
|
|
/**
|
|
* 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<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),
|
|
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<import('postgres')>} 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<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
|
|
}
|
|
|
|
/**
|
|
* 게시물에 포함된 내부 업로드 파일 크기를 추산한다.
|
|
* @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 작업
|
|
* @returns {Promise<void>}
|
|
*/
|
|
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: [
|
|
'<div style="font-family:Arial,sans-serif;line-height:1.6;color:#15171a">',
|
|
`<h2>${escapeHtml(siteTitle)} 게시물 Export가 준비되었습니다.</h2>`,
|
|
`<p>범위: <strong>${escapeHtml(job.rangeLabel || '전체')}</strong></p>`,
|
|
`<p>게시물 ${Number(job.postCount || 0).toLocaleString()}개, 분할 파일 ${fileCount.toLocaleString()}개가 생성되었습니다.</p>`,
|
|
`<p>관리자 설정 화면에서 필요한 파일을 다운로드해 주세요. 산출물은 최대 ${Number(job.retentionDays || DEFAULT_RETENTION_DAYS)}일 동안 보관됩니다.</p>`,
|
|
`<p><a href="${escapeHtml(settingsUrl)}">Export 다운로드 화면 열기</a></p>`,
|
|
'</div>'
|
|
].join('')
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 만료된 Export 작업과 파일을 정리한다.
|
|
* @returns {Promise<number>} 삭제한 작업 수
|
|
*/
|
|
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<Array>} 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<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 {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<Object>} 생성된 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<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 파일 생성을 시작했습니다.',
|
|
error_detail = '',
|
|
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 = '생성이 완료되었습니다.',
|
|
error_detail = '',
|
|
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 : '알 수 없는 오류'
|
|
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<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
|
|
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<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)
|
|
|
|
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<void>}
|
|
*/
|
|
export const queuePendingPostExportJobs = async () => {
|
|
const jobIds = await listQueuedPostExportJobIds()
|
|
jobIds.forEach(queuePostExportJobRun)
|
|
}
|
|
|
|
/**
|
|
* 실패한 Export 작업을 준비 완료 파일 이후부터 다시 실행할 수 있게 대기 상태로 되돌린다.
|
|
* @param {string} jobId - Export 작업 ID
|
|
* @returns {Promise<Object|null>} 재시도 대상 작업
|
|
*/
|
|
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<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])
|
|
}
|
|
|
|
/**
|
|
* 게시물 Export 작업과 생성 파일을 삭제한다.
|
|
* @param {string} jobId - Export 작업 ID
|
|
* @returns {Promise<boolean>} 삭제 여부
|
|
*/
|
|
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
|
|
}
|