게시물 Export 기간 선택과 삭제 추가 v1.5.23
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
32
server/routes/admin/api/posts/export-jobs/[jobId].delete.js
Normal file
32
server/routes/admin/api/posts/export-jobs/[jobId].delete.js
Normal 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 }
|
||||
})
|
||||
Reference in New Issue
Block a user