From 4db1b21ad5115245836dbb6554d8164d720d2a4b Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 31 Mar 2026 17:39:54 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=95=B4=EC=8B=9C=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 46 ++++++++++++++++++++ backend/src/lib/image-storage.js | 74 +++++++++++++++++++++++++++++--- docs/update.md | 5 +++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 3054eea..5e5f8a6 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -64,6 +64,20 @@ function mapGameItemRow(row) { } } +function mapImageAssetRow(row) { + if (!row) return null + return { + id: row.id, + contentHash: row.content_hash, + src: row.src || '', + mimeType: row.mime_type || 'image/webp', + byteSize: Number(row.byte_size || 0), + originalByteSize: Number(row.original_byte_size || 0), + width: Number(row.width || 0), + height: Number(row.height || 0), + createdAt: Number(row.created_at || 0), + } +} function mapTierListRow(row) { if (!row) return null return { @@ -270,6 +284,20 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + await query(` + CREATE TABLE IF NOT EXISTS image_assets ( + id VARCHAR(64) PRIMARY KEY, + content_hash CHAR(64) NOT NULL UNIQUE, + src VARCHAR(255) NOT NULL UNIQUE, + mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp', + byte_size INT UNSIGNED NOT NULL, + original_byte_size INT UNSIGNED NOT NULL, + width INT UNSIGNED NOT NULL DEFAULT 0, + height INT UNSIGNED NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + INDEX idx_image_assets_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) await query(` CREATE TABLE IF NOT EXISTS template_requests ( id VARCHAR(64) PRIMARY KEY, @@ -522,6 +550,22 @@ async function updateGameThumbnail(gameId, thumbnailSrc) { return findGameById(gameId) } +async function findImageAssetByHash(contentHash) { + const rows = await query( + 'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1', + [contentHash] + ) + return mapImageAssetRow(rows[0]) +} + +async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) { + const createdAt = now() + await query( + 'INSERT INTO image_assets (id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, contentHash, src, mimeType, byteSize, originalByteSize, width, height, createdAt] + ) + return findImageAssetByHash(contentHash) +} async function createGameItem({ id, gameId, src, label }) { const createdAt = now() await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ @@ -1404,6 +1448,8 @@ module.exports = { getGameDetail, createGame, updateGameThumbnail, + findImageAssetByHash, + createImageAsset, createGameItem, updateGameItemLabel, deleteGameItem, diff --git a/backend/src/lib/image-storage.js b/backend/src/lib/image-storage.js index 4bae265..d19edb9 100644 --- a/backend/src/lib/image-storage.js +++ b/backend/src/lib/image-storage.js @@ -1,9 +1,12 @@ const fs = require('fs/promises') const path = require('path') +const crypto = require('crypto') const sharp = require('sharp') const { nanoid } = require('nanoid') +const { findImageAssetByHash, createImageAsset } = require('../db') const UPLOAD_ROOT = path.join(__dirname, '..', '..', 'uploads') +const OPTIMIZED_DIR = 'assets' function ensureImageMimeType(file) { return typeof file?.mimetype === 'string' && file.mimetype.startsWith('image/') @@ -54,18 +57,75 @@ async function writeOptimizedImage({ .webp({ quality }) .toBuffer({ resolveWithObject: true }) + const contentHash = crypto.createHash('sha256').update(data).digest('hex') + const existing = await findImageAssetByHash(contentHash) + if (existing) { + return { + src: existing.src, + size: existing.byteSize, + originalSize: existing.originalByteSize, + width: existing.width, + height: existing.height, + contentHash: existing.contentHash, + reused: true, + directory, + } + } + const filename = String(Date.now()) + '-' + nanoid() + '.webp' - const absoluteDir = path.join(UPLOAD_ROOT, directory) + const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR) const absolutePath = path.join(absoluteDir, filename) + const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename + await fs.mkdir(absoluteDir, { recursive: true }) await fs.writeFile(absolutePath, data) - return { - src: '/uploads/' + directory + '/' + filename, - size: data.length, - originalSize: file.size || file.buffer.length, - width: info.width || 0, - height: info.height || 0, + try { + const asset = await createImageAsset({ + id: nanoid(), + contentHash, + src, + mimeType: 'image/webp', + byteSize: data.length, + originalByteSize: file.size || file.buffer.length, + width: info.width || 0, + height: info.height || 0, + }) + + return { + src: asset.src, + size: asset.byteSize, + originalSize: asset.originalByteSize, + width: asset.width, + height: asset.height, + contentHash: asset.contentHash, + reused: false, + directory, + } + } catch (error) { + try { + await fs.unlink(absolutePath) + } catch (unlinkError) { + if (unlinkError?.code !== 'ENOENT') throw unlinkError + } + + if (error?.code === 'ER_DUP_ENTRY') { + const asset = await findImageAssetByHash(contentHash) + if (asset) { + return { + src: asset.src, + size: asset.byteSize, + originalSize: asset.originalByteSize, + width: asset.width, + height: asset.height, + contentHash: asset.contentHash, + reused: true, + directory, + } + } + } + + throw error } } diff --git a/docs/update.md b/docs/update.md index 9454175..04ec69f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.3.1 +- 최적화된 WebP 결과물 기준으로 SHA-256 해시를 계산해, 같은 이미지가 다시 업로드되면 새 파일을 저장하지 않고 기존 자산을 재사용하도록 중복 이미지 해시 검사를 추가함. +- 이미지 자산 메타데이터를 저장하는 `image_assets` 테이블을 도입해 파일 경로, 해시, 원본 대비 최적화 용량, 해상도를 함께 기록하도록 확장함. +- 중복 업로드 경쟁 상황에서도 고유 해시 충돌을 안전하게 처리하고, 새 파일 저장에 실패하면 즉시 정리하도록 업로드 헬퍼를 보강함. + ## 2026-03-31 v1.3.0 - 백엔드 업로드 파이프라인을 메모리 기반으로 전환하고, 대표 썸네일·게임 썸네일·커스텀 아이템·게임 기본 아이템·아바타를 서버에서 즉시 WebP로 변환해 저장하도록 정리함. - 아이템 이미지는 최대 512px 규격으로 리사이즈하고, 티어표/게임 썸네일은 긴 변 기준 1280px 안쪽으로 최적화해 원본 이미지를 별도로 보관하지 않는 흐름으로 전환함.