278 lines
7.6 KiB
JavaScript
278 lines
7.6 KiB
JavaScript
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),
|
|
processedCount: Number(row.processed_count || 0),
|
|
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),
|
|
expiresAt: row.expires_at ? row.expires_at.toISOString() : null,
|
|
message: row.message || '',
|
|
progressMessage: row.progress_message || '',
|
|
createdAt: row.created_at.toISOString(),
|
|
updatedAt: row.updated_at.toISOString(),
|
|
startedAt: row.started_at ? row.started_at.toISOString() : null,
|
|
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<import('postgres')>} sql - PostgreSQL 클라이언트
|
|
* @returns {Promise<string>} 사이트 이름
|
|
*/
|
|
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<Array>} 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<Object>} 생성된 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: []
|
|
})
|
|
}
|