게시물 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
}

View File

@@ -1,4 +1,4 @@
import { readBody } from 'h3'
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../utils/admin-auth'
import {
@@ -7,10 +7,101 @@ import {
} from '../../../../repositories/post-export-repository'
const postExportJobInputSchema = z.object({
dateRangeMode: z.enum(['all', 'year', 'month', 'custom']).optional().default('all'),
year: z.number().int().min(1970).max(9999).optional(),
month: z.number().int().min(1).max(12).optional(),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
chunkSize: z.number().int().min(1).max(500).optional(),
retentionDays: z.number().int().min(1).max(100).optional()
}).default({})
/**
* KST 기준 날짜 시작 시각을 만든다.
* @param {string} value - YYYY-MM-DD 날짜
* @returns {Date} 날짜 시작 시각
*/
const createKstDateStart = (value) => new Date(`${value}T00:00:00+09:00`)
/**
* KST 기준 다음 날짜 시작 시각을 만든다.
* @param {string} value - YYYY-MM-DD 날짜
* @returns {Date} 다음 날짜 시작 시각
*/
const createKstNextDateStart = (value) => {
const date = createKstDateStart(value)
date.setUTCDate(date.getUTCDate() + 1)
return date
}
/**
* 두 자리 숫자 문자열을 만든다.
* @param {number} value - 숫자
* @returns {string} 두 자리 문자열
*/
const pad2 = (value) => String(value).padStart(2, '0')
/**
* 요청 입력에서 Export 날짜 범위를 만든다.
* @param {z.infer<typeof postExportJobInputSchema>} input - 요청 입력
* @returns {{ dateFrom: Date|null, dateTo: Date|null, rangeLabel: string }} 날짜 범위
*/
const createExportDateRange = (input) => {
if (input.dateRangeMode === 'year') {
const year = input.year || new Date().getFullYear()
return {
dateFrom: createKstDateStart(`${year}-01-01`),
dateTo: createKstDateStart(`${year + 1}-01-01`),
rangeLabel: `${year}`
}
}
if (input.dateRangeMode === 'month') {
const now = new Date()
const year = input.year || now.getFullYear()
const month = input.month || now.getMonth() + 1
const nextYear = month === 12 ? year + 1 : year
const nextMonth = month === 12 ? 1 : month + 1
return {
dateFrom: createKstDateStart(`${year}-${pad2(month)}-01`),
dateTo: createKstDateStart(`${nextYear}-${pad2(nextMonth)}-01`),
rangeLabel: `${year}-${pad2(month)}`
}
}
if (input.dateRangeMode === 'custom') {
if (!input.dateFrom || !input.dateTo) {
throw createError({
statusCode: 400,
statusMessage: '날짜 범위를 선택해 주세요.'
})
}
const dateFrom = createKstDateStart(input.dateFrom)
const dateTo = createKstNextDateStart(input.dateTo)
if (dateFrom >= dateTo) {
throw createError({
statusCode: 400,
statusMessage: '시작일은 종료일보다 늦을 수 없습니다.'
})
}
return {
dateFrom,
dateTo,
rangeLabel: `${input.dateFrom}_${input.dateTo}`
}
}
return {
dateFrom: null,
dateTo: null,
rangeLabel: '전체'
}
}
/**
* 관리자 게시물 Export 작업 요청 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -19,11 +110,15 @@ const postExportJobInputSchema = z.object({
export default defineEventHandler(async (event) => {
const adminSession = requireAdminSession(event)
const input = postExportJobInputSchema.parse(await readBody(event))
const dateRange = createExportDateRange(input)
const job = await createPostExportJob({
requestedBy: adminSession.userId,
requestedEmail: adminSession.email,
scope: 'all',
dateFrom: dateRange.dateFrom,
dateTo: dateRange.dateTo,
rangeLabel: dateRange.rangeLabel,
chunkSize: input.chunkSize,
retentionDays: input.retentionDays
})

View File

@@ -0,0 +1,32 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../../utils/admin-auth'
import { deletePostExportJob } from '../../../../../repositories/post-export-repository'
/**
* 관리자 게시물 Export 작업 삭제 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: boolean }>} 삭제 결과
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const jobId = getRouterParam(event, 'jobId')
if (!jobId) {
throw createError({
statusCode: 400,
statusMessage: 'Export 작업 ID가 필요합니다.'
})
}
const deleted = await deletePostExportJob(jobId)
if (!deleted) {
throw createError({
statusCode: 404,
statusMessage: 'Export 작업을 찾을 수 없습니다.'
})
}
return { ok: true }
})