import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * 미리보기 이미지 저장 디렉터리 * @returns {string} */ export function getPreviewsDir() { const dir = process.env.PREVIEWS_PATH || path.join(__dirname, '..', '..', 'data', 'previews'); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } return dir; } /** * base64 data URL을 PNG 파일로 저장 * @param {string} shareCode * @param {string} dataUrl * @returns {string} 파일명 */ export function savePreviewImage(shareCode, dataUrl) { if (!dataUrl || !dataUrl.startsWith('data:image')) { return null; } const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ''); const fileName = `${shareCode}.png`; fs.writeFileSync(path.join(getPreviewsDir(), fileName), Buffer.from(base64, 'base64')); return fileName; } /** * @param {string} shareCode * @returns {string|null} */ export function getPreviewFilePath(shareCode) { const filePath = path.join(getPreviewsDir(), `${shareCode}.png`); return fs.existsSync(filePath) ? filePath : null; } /** * @param {string} shareCode * @param {string|null} [previewImageRecord] DB preview_image 컬럼 * @returns {string|null} */ export function getPreviewUrl(shareCode, previewImageRecord = null) { const hasFile = Boolean(getPreviewFilePath(shareCode)); if (hasFile || previewImageRecord) { return `/api/v1/decks/${shareCode}/preview`; } return null; } /** * 공유 코드에 연결된 미리보기 파일 삭제 * @param {string} shareCode * @returns {boolean} */ export function deletePreviewImage(shareCode) { const filePath = path.join(getPreviewsDir(), `${shareCode}.png`); if (!fs.existsSync(filePath)) return false; try { fs.unlinkSync(filePath); return true; } catch (err) { console.warn(`[preview] 삭제 실패: ${filePath}`, err); return false; } } /** * DB에서 참조하지 않는 미리보기 파일 목록 * @param {Iterable} referencedShareCodes * @returns {Array<{fileName: string, size: number, modifiedAt: string}>} */ export function listOrphanPreviewImages(referencedShareCodes) { const referenced = new Set([...referencedShareCodes].map((code) => `${code}.png`)); return fs .readdirSync(getPreviewsDir(), { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png')) .filter((entry) => !referenced.has(entry.name)) .map((entry) => { const stat = fs.statSync(path.join(getPreviewsDir(), entry.name)); return { fileName: entry.name, size: stat.size, modifiedAt: stat.mtime.toISOString(), }; }) .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)); } /** * 미참조 미리보기 파일 일괄 삭제 * @param {Iterable} referencedShareCodes * @returns {{deletedCount: number, deletedBytes: number}} */ export function deleteOrphanPreviewImages(referencedShareCodes) { const orphans = listOrphanPreviewImages(referencedShareCodes); let deletedBytes = 0; orphans.forEach((file) => { fs.unlinkSync(path.join(getPreviewsDir(), file.fileName)); deletedBytes += file.size; }); return { deletedCount: orphans.length, deletedBytes }; }