게시물 Export 일괄 다운로드와 재시도 추가 v1.5.24

This commit is contained in:
2026-06-01 16:02:24 +09:00
parent a4c1b42369
commit 5735fd5046
12 changed files with 321 additions and 12 deletions

View File

@@ -1,6 +1,8 @@
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'
@@ -26,6 +28,18 @@ 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
/**
* 파일명에 쓸 수 없는 문자를 정리한다.
* @param {string} value - 원본 문자열
@@ -463,6 +477,92 @@ const getExportSiteName = async (sql) => {
return rows[0]?.title || getDefaultSiteSettings().title
}
/**
* 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 작업 목록
@@ -474,6 +574,8 @@ export const listPostExportJobs = async () => {
return []
}
await cleanupExpiredPostExportJobs()
const jobRows = await sql`
SELECT *
FROM post_export_jobs
@@ -547,6 +649,8 @@ export const createPostExportJob = async (input) => {
throw new Error('DATABASE_REQUIRED')
}
await cleanupExpiredPostExportJobs()
const scope = input.scope === 'author' ? 'author' : 'all'
const chunkSize = normalizeChunkSize(input.chunkSize)
const retentionDays = normalizeRetentionDays(input.retentionDays)
@@ -960,6 +1064,22 @@ export const runPostExportJob = async (jobId) => {
}
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 {
@@ -993,6 +1113,49 @@ export const queuePendingPostExportJobs = async () => {
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 = '준비 완료 파일은 유지하고 실패 지점부터 다시 생성합니다.',
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

View File

@@ -0,0 +1,32 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../../../utils/admin-auth'
import { retryPostExportJob } from '../../../../../../repositories/post-export-repository'
/**
* 관리자 게시물 Export 작업 재시도 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 재시도 대상 Export 작업
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const jobId = getRouterParam(event, 'jobId')
if (!jobId) {
throw createError({
statusCode: 400,
statusMessage: 'Export 작업 ID가 필요합니다.'
})
}
const job = await retryPostExportJob(jobId)
if (!job) {
throw createError({
statusCode: 400,
statusMessage: '재시도할 수 있는 실패 작업이 아닙니다.'
})
}
return job
})