게시물 가져오기 중복 복원 방지

This commit is contained in:
2026-07-23 18:17:26 +09:00
parent f510a88e56
commit 85b6fe7033
10 changed files with 396 additions and 72 deletions

View File

@@ -1,4 +1,5 @@
import { createWriteStream } from 'node:fs'
import { createReadStream, createWriteStream } from 'node:fs'
import { createHash } from 'node:crypto'
import { mkdir, rm, stat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { pipeline } from 'node:stream/promises'
@@ -31,6 +32,21 @@ const sanitizeFilenameSegment = (value) => String(value || '')
.replace(/^-|-$/g, '')
.slice(0, 80) || 'asset'
/**
* 읽기 스트림의 SHA-256 해시를 만든다.
* @param {NodeJS.ReadableStream} stream - 읽기 스트림
* @returns {Promise<string>} 해시 문자열
*/
const createStreamHash = async (stream) => {
const hash = createHash('sha256')
for await (const chunk of stream) {
hash.update(chunk)
}
return hash.digest('hex')
}
/**
* 슬러그에 안전한 문자열로 정리한다.
* @param {string} value - 원본 슬러그
@@ -330,21 +346,65 @@ const pickUniqueDiskFileName = async (directoryPath, originalName) => {
throw new Error('IMPORT_ASSET_FILENAME_FAILED')
}
/**
* 이미 저장된 같은 자산 파일을 찾는다.
* @param {string} directoryPath - 저장 디렉터리
* @param {string} originalName - 원본 파일명
* @param {{ uncompressedSize: number, createReadStream: () => NodeJS.ReadableStream }} entry - ZIP 엔트리
* @returns {Promise<{ fileName: string, filePath: string }|null>} 재사용 파일
*/
const findReusableDiskAsset = async (directoryPath, originalName, entry) => {
const extension = extname(originalName || '') || '.bin'
const stem = sanitizeFilenameSegment(String(originalName || '').replace(/\.[^.]+$/g, '')) || 'asset'
const entrySize = Number(entry.uncompressedSize || 0)
let entryHash = ''
for (let suffix = 1; suffix < 10000; suffix += 1) {
const fileName = suffix === 1 ? `${stem}${extension}` : `${stem}-${suffix}${extension}`
const filePath = join(directoryPath, fileName)
let fileStat = null
try {
fileStat = await stat(filePath)
} catch {
return null
}
if (entrySize > 0 && Number(fileStat.size || 0) !== entrySize) {
continue
}
entryHash ||= await createStreamHash(entry.createReadStream())
if (await createStreamHash(createReadStream(filePath)) === entryHash) {
return {
fileName,
filePath
}
}
}
return null
}
/**
* Import 자산을 업로드 폴더에 저장한다.
* @param {Object} input - 저장 입력
* @param {Map<string, Object>} input.entryMap - ZIP 엔트리 맵
* @param {string} input.postFolder - 게시물 폴더
* @param {Set<string>} input.assetPaths - 자산 경로 목록
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string> }>} 저장 결과
* @param {Map<string, string>} input.savedAssetUrls - ZIP 엔트리별 저장 URL 캐시
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string>, savedCount: number, reusedCount: number }>} 저장 결과
*/
const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
const saveImportAssets = async ({ entryMap, postFolder, assetPaths, savedAssetUrls }) => {
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const directoryPath = join(getUploadRoot(), 'posts', year, month)
const replacements = new Map()
const missingAssets = []
let savedCount = 0
let reusedCount = 0
await mkdir(directoryPath, { recursive: true })
@@ -357,6 +417,28 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
continue
}
const cachedUrl = savedAssetUrls.get(entryPath)
if (cachedUrl) {
replacements.set(assetPath, cachedUrl)
replacements.set(assetPath.replace(/^\.\//, ''), cachedUrl)
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, cachedUrl)
reusedCount += 1
continue
}
const reusableAsset = await findReusableDiskAsset(directoryPath, basename(entryPath), entry)
if (reusableAsset) {
const publicUrl = `${UPLOAD_BASE_URL}/posts/${year}/${month}/${reusableAsset.fileName}`
savedAssetUrls.set(entryPath, publicUrl)
replacements.set(assetPath, publicUrl)
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
reusedCount += 1
continue
}
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, basename(entryPath))
try {
await pipeline(
@@ -377,14 +459,18 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
origin: 'import'
})
savedAssetUrls.set(entryPath, publicUrl)
replacements.set(assetPath, publicUrl)
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
savedCount += 1
}
return {
replacements,
missingAssets
missingAssets,
savedCount,
reusedCount
}
}
@@ -432,45 +518,42 @@ const collectImportAssetPaths = ({ frontmatter, content }) => {
}
/**
* 게시물 슬러그 중복을 피한다.
* @param {string} baseSlug - 기준 슬러그
* @param {Set<string>} reservedSlugs - 이번 Import에서 예약된 슬러그
* @returns {Promise<string>} 고유 슬러그
* 게시물 슬러그가 이미 존재하는지 확인한다.
* @param {string} slug - 게시물 슬러그
* @returns {Promise<boolean>} 존재 여부
*/
const createUniquePostSlug = async (baseSlug, reservedSlugs) => {
const postSlugExists = async (slug) => {
const sql = getPostgresClient()
const base = sanitizeSlug(baseSlug)
let next = base
let suffix = 2
while (reservedSlugs.has(next)) {
next = `${base}-${suffix}`
suffix += 1
}
if (!sql) {
reservedSlugs.add(next)
return next
return false
}
while (suffix < 10000) {
const rows = await sql`
SELECT 1
FROM posts
WHERE slug = ${next}
LIMIT 1
`
const rows = await sql`
SELECT 1
FROM posts
WHERE slug = ${slug}
LIMIT 1
`
if (!rows.length && !reservedSlugs.has(next)) {
reservedSlugs.add(next)
return next
}
return rows.length > 0
}
next = `${base}-${suffix}`
suffix += 1
/**
* Import 게시물 슬러그를 정리한다.
* @param {string} baseSlug - 기준 슬러그
* @param {Set<string>} reservedSlugs - 이번 Import에서 예약된 슬러그
* @returns {{ slug: string, duplicateInZip: boolean }} 정리된 슬러그
*/
const reserveImportPostSlug = (baseSlug, reservedSlugs) => {
const slug = sanitizeSlug(baseSlug)
const duplicateInZip = reservedSlugs.has(slug)
reservedSlugs.add(slug)
return {
slug,
duplicateInZip
}
throw new Error('IMPORT_SLUG_FAILED')
}
/**
@@ -567,7 +650,7 @@ const collectMarkdownPosts = async (entries) => {
/**
* Export ZIP을 게시물로 가져온다.
* @param {{ zipFilePath: string, authorId: string }} input - Import 입력
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
* @returns {Promise<{ importedCount: number, skippedCount: number, assetCount: number, reusedAssetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object>, skippedPosts: Array<Object> }>} Import 결과
*/
export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
const entries = await openZipFileEntries(zipFilePath, {
@@ -584,25 +667,55 @@ export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
}
const reservedSlugs = new Set()
const savedAssetUrls = new Map()
const importedPosts = []
const skippedPosts = []
const warnings = []
let importedAssetCount = 0
let reusedAssetCount = 0
for (const markdownPost of markdownPosts) {
const { frontmatter, content, postFolder } = markdownPost
const title = String(frontmatter.title || basename(markdownPost.path).replace(MARKDOWN_EXTENSION_PATTERN, '') || 'Imported Post').trim()
const { slug, duplicateInZip } = reserveImportPostSlug(frontmatter.slug || title, reservedSlugs)
if (duplicateInZip) {
skippedPosts.push({
title,
slug,
reason: 'duplicate-in-zip'
})
warnings.push(`${markdownPost.path}: ZIP 안에 같은 slug가 있어 건너뛰었습니다. (${slug})`)
continue
}
if (await postSlugExists(slug)) {
skippedPosts.push({
title,
slug,
reason: 'existing-slug'
})
continue
}
const assetPaths = collectImportAssetPaths({ frontmatter, content })
const { replacements, missingAssets } = await saveImportAssets({
const {
replacements,
missingAssets,
savedCount,
reusedCount
} = await saveImportAssets({
entryMap,
postFolder,
assetPaths
assetPaths,
savedAssetUrls
})
importedAssetCount += replacements.size ? new Set(replacements.values()).size : 0
importedAssetCount += savedCount
reusedAssetCount += reusedCount
missingAssets.forEach((assetPath) => {
warnings.push(`${markdownPost.path}: ZIP 내부 자산을 찾지 못했습니다. (${assetPath})`)
})
const title = String(frontmatter.title || basename(markdownPost.path).replace(MARKDOWN_EXTENSION_PATTERN, '') || 'Imported Post').trim()
const slug = await createUniquePostSlug(frontmatter.slug || title, reservedSlugs)
const featuredImage = resolveImportedImageUrl(frontmatter.featured_image, replacements)
const ogImage = resolveImportedImageUrl(frontmatter.og_image, replacements)
const status = normalizePostStatus(frontmatter.status)
@@ -610,36 +723,54 @@ export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
? normalizeIsoDate(frontmatter.published_at) || new Date().toISOString()
: normalizeIsoDate(frontmatter.published_at)
const post = await createAdminPost({
title,
slug,
content: replaceAssetPaths(content, replacements),
excerpt: String(frontmatter.excerpt || ''),
featuredImage,
showFeaturedImage: Boolean(frontmatter.show_featured_image),
isFeatured: false,
seoTitle: String(frontmatter.seo_title || ''),
seoDescription: String(frontmatter.seo_description || ''),
canonicalUrl: String(frontmatter.canonical_url || ''),
noindex: Boolean(frontmatter.noindex),
ogImage,
status,
publishedAt,
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : []
}, authorId)
let post
try {
post = await createAdminPost({
title,
slug,
content: replaceAssetPaths(content, replacements),
excerpt: String(frontmatter.excerpt || ''),
featuredImage,
showFeaturedImage: Boolean(frontmatter.show_featured_image),
isFeatured: false,
seoTitle: String(frontmatter.seo_title || ''),
seoDescription: String(frontmatter.seo_description || ''),
canonicalUrl: String(frontmatter.canonical_url || ''),
noindex: Boolean(frontmatter.noindex),
ogImage,
status,
publishedAt,
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : []
}, authorId)
} catch (error) {
if (error?.code === '23505') {
skippedPosts.push({
title,
slug,
reason: 'existing-slug'
})
continue
}
throw error
}
importedPosts.push(post)
}
return {
importedCount: importedPosts.length,
skippedCount: skippedPosts.length,
assetCount: importedAssetCount,
reusedAssetCount,
warningCount: warnings.length,
warnings: warnings.slice(0, 20),
posts: importedPosts.map((post) => ({
id: post.id,
title: post.title,
slug: post.slug
}))
})),
skippedPosts: skippedPosts.slice(0, 20)
}
}

View File

@@ -11,7 +11,7 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
/**
* 관리자 게시물 Import API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
* @returns {Promise<{ importedCount: number, skippedCount: number, assetCount: number, reusedAssetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object>, skippedPosts: Array<Object> }>} Import 결과
*/
export default defineEventHandler(async (event) => {
const adminSession = requireAdminSession(event)