게시물 export 작업 기반 추가 v1.5.20

This commit is contained in:
2026-06-01 12:26:24 +09:00
parent abce690546
commit 11203ba251
13 changed files with 608 additions and 9 deletions

View File

@@ -0,0 +1,273 @@
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<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: []
})
}

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../../utils/admin-auth'
import { listPostExportJobs } from '../../../../repositories/post-export-repository'
/**
* 관리자 게시물 Export 작업 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} Export 작업 목록
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
return listPostExportJobs()
})

View File

@@ -0,0 +1,27 @@
import { readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { createPostExportJob } from '../../../../repositories/post-export-repository'
const postExportJobInputSchema = z.object({
chunkSize: z.number().int().min(1).max(500).optional(),
retentionDays: z.number().int().min(1).max(100).optional()
}).default({})
/**
* 관리자 게시물 Export 작업 요청 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 생성된 Export 작업
*/
export default defineEventHandler(async (event) => {
const adminSession = requireAdminSession(event)
const input = postExportJobInputSchema.parse(await readBody(event))
return createPostExportJob({
requestedBy: adminSession.userId,
requestedEmail: adminSession.email,
scope: 'all',
chunkSize: input.chunkSize,
retentionDays: input.retentionDays
})
})