게시물 Export 일괄 다운로드와 재시도 추가 v1.5.24
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
/**
|
||||
* 파일명에 쓸 수 없는 문자를 정리한다.
|
||||
* @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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user