게시물 Export 기간 선택과 삭제 추가 v1.5.23

This commit is contained in:
2026-06-01 15:35:45 +09:00
parent f8621d49d8
commit a4c1b42369
13 changed files with 554 additions and 33 deletions

View File

@@ -1,4 +1,4 @@
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
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 { getDefaultSiteSettings } from '../utils/site-settings'
@@ -283,6 +283,9 @@ const mapPostExportJobRow = (row) => ({
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),
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 || '',
@@ -343,6 +346,107 @@ const normalizeRetentionDays = (value) => {
return Math.min(Math.max(Math.trunc(parsed), 1), DEFAULT_RETENTION_DAYS)
}
/**
* 날짜 입력을 안전하게 보정한다.
* @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 클라이언트
@@ -429,6 +533,9 @@ export const listQueuedPostExportJobIds = async () => {
* @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.retentionDays] - 산출물 보존 일수
* @returns {Promise<Object>} 생성된 export 작업
@@ -443,19 +550,24 @@ export const createPostExportJob = async (input) => {
const scope = input.scope === 'author' ? 'author' : 'all'
const chunkSize = normalizeChunkSize(input.chunkSize)
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 dateCondition = createPostExportDateCondition(transaction, exportDateRange)
const [{ count }] = scope === 'author'
? await transaction`
SELECT COUNT(*)::int AS count
FROM posts
WHERE author_id = ${input.requestedBy}
AND ${dateCondition}
`
: await transaction`
SELECT COUNT(*)::int AS count
FROM posts
WHERE ${dateCondition}
`
const postCount = Number(count || 0)
const status = postCount > 0 ? EXPORT_STATUS.QUEUED : EXPORT_STATUS.READY
@@ -471,6 +583,9 @@ export const createPostExportJob = async (input) => {
post_count,
chunk_size,
retention_days,
date_from,
date_to,
range_label,
expires_at,
message,
completed_at
@@ -483,6 +598,9 @@ export const createPostExportJob = async (input) => {
${postCount},
${chunkSize},
${retentionDays},
${exportDateRange.dateFrom},
${exportDateRange.dateTo},
${exportDateRange.rangeLabel},
${expiresAt},
${message},
${postCount > 0 ? null : new Date()}
@@ -502,7 +620,7 @@ export const createPostExportJob = async (input) => {
partIndex: index + 1,
postStart,
postEnd,
fileName: `${siteName}_${postStart}-${postEnd}.zip`,
fileName: `${siteName}_${fileRangeName}_${postStart}-${postEnd}.zip`,
status: EXPORT_FILE_STATUS.PENDING
}
})
@@ -710,6 +828,10 @@ const markPostExportFileFailed = async (sql, fileId) => {
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`
@@ -720,6 +842,7 @@ const listPostsForExportFile = async (sql, job, file) => {
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}
@@ -734,6 +857,7 @@ const listPostsForExportFile = async (sql, job, file) => {
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}
@@ -896,3 +1020,45 @@ export const getReadyPostExportFile = async (fileId) => {
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
}