게시물 export 작업 기반 추가 v1.5.20
This commit is contained in:
273
server/repositories/post-export-repository.js
Normal file
273
server/repositories/post-export-repository.js
Normal 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: []
|
||||
})
|
||||
}
|
||||
13
server/routes/admin/api/posts/export-jobs.get.js
Normal file
13
server/routes/admin/api/posts/export-jobs.get.js
Normal 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()
|
||||
})
|
||||
27
server/routes/admin/api/posts/export-jobs.post.js
Normal file
27
server/routes/admin/api/posts/export-jobs.post.js
Normal 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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user