import { getDefaultSiteSettings } from '../utils/site-settings' import { getPostgresClient } from './postgres-client' const EXPORT_STATUS = { QUEUED: 'queued', READY: 'ready' } const EXPORT_FILE_STATUS = { PENDING: 'pending' } const DEFAULT_CHUNK_SIZE = 100 const MAX_CHUNK_SIZE = 500 const DEFAULT_RETENTION_DAYS = 100 /** * 파일명에 쓸 수 없는 문자를 정리한다. * @param {string} value - 원본 문자열 * @returns {string} 파일명 안전 문자열 */ const sanitizeFilenameSegment = (value) => String(value || '') .trim() .replace(/[\\/:*?"<>|]+/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 80) || 'sori.studio' /** * export 작업 행을 응답 구조로 변환한다. * @param {Object} row - DB 행 * @returns {Object} export 작업 */ const mapPostExportJobRow = (row) => ({ id: row.id, requestedBy: row.requested_by || null, requestedEmail: row.requested_email || '', status: row.status, scope: row.scope, postCount: Number(row.post_count || 0), chunkSize: Number(row.chunk_size || DEFAULT_CHUNK_SIZE), retentionDays: Number(row.retention_days || DEFAULT_RETENTION_DAYS), expiresAt: row.expires_at ? row.expires_at.toISOString() : null, message: row.message || '', createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), completedAt: row.completed_at ? row.completed_at.toISOString() : null, files: row.files || [] }) /** * export 분할 파일 행을 응답 구조로 변환한다. * @param {Object} row - DB 행 * @returns {Object} export 분할 파일 */ const mapPostExportFileRow = (row) => ({ id: row.id, jobId: row.job_id, partIndex: Number(row.part_index || 0), postStart: Number(row.post_start || 0), postEnd: Number(row.post_end || 0), fileName: row.file_name, filePath: row.file_path || '', fileSizeBytes: Number(row.file_size_bytes || 0), status: row.status, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), completedAt: row.completed_at ? row.completed_at.toISOString() : null }) /** * 요청된 분할 크기를 안전 범위로 보정한다. * @param {unknown} value - 입력 분할 크기 * @returns {number} 보정된 분할 크기 */ const normalizeChunkSize = (value) => { const parsed = Number(value) if (!Number.isFinite(parsed)) { return DEFAULT_CHUNK_SIZE } return Math.min(Math.max(Math.trunc(parsed), 1), MAX_CHUNK_SIZE) } /** * export 산출물 보존 일수를 안전 범위로 보정한다. * @param {unknown} value - 입력 보존 일수 * @returns {number} 보정된 보존 일수 */ const normalizeRetentionDays = (value) => { const parsed = Number(value) if (!Number.isFinite(parsed)) { return DEFAULT_RETENTION_DAYS } return Math.min(Math.max(Math.trunc(parsed), 1), DEFAULT_RETENTION_DAYS) } /** * 사이트 이름을 조회한다. * @param {ReturnType} sql - PostgreSQL 클라이언트 * @returns {Promise} 사이트 이름 */ const getExportSiteName = async (sql) => { const rows = await sql` SELECT title FROM site_settings ORDER BY updated_at DESC LIMIT 1 ` return rows[0]?.title || getDefaultSiteSettings().title } /** * export 작업 목록 조회 * @returns {Promise} export 작업 목록 */ export const listPostExportJobs = async () => { const sql = getPostgresClient() if (!sql) { return [] } const jobRows = await sql` SELECT * FROM post_export_jobs ORDER BY created_at DESC LIMIT 20 ` if (jobRows.length === 0) { return [] } const jobIds = jobRows.map((row) => row.id) const fileRows = await sql` SELECT * FROM post_export_files WHERE job_id = ANY(${jobIds}) ORDER BY job_id, part_index ASC ` const filesByJobId = fileRows.reduce((groups, row) => { const key = row.job_id groups[key] = groups[key] || [] groups[key].push(mapPostExportFileRow(row)) return groups }, {}) return jobRows.map((row) => mapPostExportJobRow({ ...row, files: filesByJobId[row.id] || [] })) } /** * 게시물 export 작업 요청 생성 * @param {Object} input - 작업 요청 입력 * @param {string} input.requestedBy - 요청 관리자 회원 ID * @param {string} input.requestedEmail - 요청 관리자 이메일 * @param {'all'|'author'} [input.scope] - export 범위 * @param {number} [input.chunkSize] - 분할당 게시물 수 * @param {number} [input.retentionDays] - 산출물 보존 일수 * @returns {Promise} 생성된 export 작업 */ export const createPostExportJob = async (input) => { const sql = getPostgresClient() if (!sql) { throw new Error('DATABASE_REQUIRED') } const scope = input.scope === 'author' ? 'author' : 'all' const chunkSize = normalizeChunkSize(input.chunkSize) const retentionDays = normalizeRetentionDays(input.retentionDays) const siteName = sanitizeFilenameSegment(await getExportSiteName(sql)) const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000) const [createdJob] = await sql.begin(async (transaction) => { const [{ count }] = scope === 'author' ? await transaction` SELECT COUNT(*)::int AS count FROM posts WHERE author_id = ${input.requestedBy} ` : await transaction` SELECT COUNT(*)::int AS count FROM posts ` const postCount = Number(count || 0) const status = postCount > 0 ? EXPORT_STATUS.QUEUED : EXPORT_STATUS.READY const message = postCount > 0 ? 'Export 작업이 대기열에 등록되었습니다.' : '내보낼 게시물이 없습니다.' const jobRows = await transaction` INSERT INTO post_export_jobs ( requested_by, requested_email, status, scope, post_count, chunk_size, retention_days, expires_at, message, completed_at ) VALUES ( ${input.requestedBy}, ${input.requestedEmail || ''}, ${status}, ${scope}, ${postCount}, ${chunkSize}, ${retentionDays}, ${expiresAt}, ${message}, ${postCount > 0 ? null : new Date()} ) RETURNING * ` const job = jobRows[0] if (postCount > 0) { const partCount = Math.ceil(postCount / chunkSize) const fileInputs = Array.from({ length: partCount }, (_, index) => { const postStart = index * chunkSize + 1 const postEnd = Math.min((index + 1) * chunkSize, postCount) return { jobId: job.id, partIndex: index + 1, postStart, postEnd, fileName: `${siteName}_${postStart}-${postEnd}.zip`, status: EXPORT_FILE_STATUS.PENDING } }) for (const fileInput of fileInputs) { await transaction` INSERT INTO post_export_files ( job_id, part_index, post_start, post_end, file_name, status ) VALUES ( ${fileInput.jobId}, ${fileInput.partIndex}, ${fileInput.postStart}, ${fileInput.postEnd}, ${fileInput.fileName}, ${fileInput.status} ) ` } } return [job] }) return (await listPostExportJobs()).find((job) => job.id === createdJob.id) || mapPostExportJobRow({ ...createdJob, files: [] }) }