Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0581de6c17 | |||
| 4db1b21ad5 |
@@ -64,6 +64,37 @@ 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 mapImageOptimizationJobRow(row) {
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
sourceCategory: row.source_category || '',
|
||||||
|
targetDirectory: row.target_directory || '',
|
||||||
|
originalByteSize: Number(row.original_byte_size || 0),
|
||||||
|
optimizedByteSize: Number(row.optimized_byte_size || 0),
|
||||||
|
reusedAsset: !!row.reused_asset,
|
||||||
|
errorMessage: row.error_message || '',
|
||||||
|
queuedAt: Number(row.queued_at || 0),
|
||||||
|
startedAt: Number(row.started_at || 0),
|
||||||
|
finishedAt: Number(row.finished_at || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
function mapTierListRow(row) {
|
function mapTierListRow(row) {
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
return {
|
return {
|
||||||
@@ -270,6 +301,38 @@ async function ensureSchema() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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 image_optimization_jobs (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'queued',
|
||||||
|
source_category VARCHAR(40) NOT NULL DEFAULT '',
|
||||||
|
target_directory VARCHAR(40) NOT NULL DEFAULT '',
|
||||||
|
original_byte_size INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
optimized_byte_size INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
reused_asset TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
error_message VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
queued_at BIGINT NOT NULL,
|
||||||
|
started_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
finished_at BIGINT NOT NULL DEFAULT 0,
|
||||||
|
INDEX idx_image_optimization_jobs_status_queued (status, queued_at),
|
||||||
|
INDEX idx_image_optimization_jobs_finished_at (finished_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS template_requests (
|
CREATE TABLE IF NOT EXISTS template_requests (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
@@ -522,6 +585,65 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
|
|||||||
return findGameById(gameId)
|
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 createImageOptimizationJob({ id, sourceCategory, targetDirectory, originalByteSize }) {
|
||||||
|
const queuedAt = now()
|
||||||
|
await query(
|
||||||
|
'INSERT INTO image_optimization_jobs (id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[id, 'queued', sourceCategory || '', targetDirectory || '', originalByteSize || 0, 0, 0, '', queuedAt, 0, 0]
|
||||||
|
)
|
||||||
|
return findImageOptimizationJobById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findImageOptimizationJobById(id) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs WHERE id = ? LIMIT 1',
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
return mapImageOptimizationJobRow(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize = 0, reusedAsset = false, errorMessage = '', startedAt, finishedAt }) {
|
||||||
|
const fields = ['status = ?', 'optimized_byte_size = ?', 'reused_asset = ?', 'error_message = ?']
|
||||||
|
const params = [status, optimizedByteSize, reusedAsset ? 1 : 0, errorMessage.slice(0, 255)]
|
||||||
|
|
||||||
|
if (typeof startedAt === 'number') {
|
||||||
|
fields.push('started_at = ?')
|
||||||
|
params.push(startedAt)
|
||||||
|
}
|
||||||
|
if (typeof finishedAt === 'number') {
|
||||||
|
fields.push('finished_at = ?')
|
||||||
|
params.push(finishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id)
|
||||||
|
await query(`UPDATE image_optimization_jobs SET ${fields.join(', ')} WHERE id = ?`, params)
|
||||||
|
return findImageOptimizationJobById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRecentImageOptimizationJobs(limit = 20) {
|
||||||
|
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20))
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs ORDER BY queued_at DESC LIMIT ${safeLimit}`
|
||||||
|
)
|
||||||
|
return rows.map(mapImageOptimizationJobRow)
|
||||||
|
}
|
||||||
async function createGameItem({ id, gameId, src, label }) {
|
async function createGameItem({ id, gameId, src, label }) {
|
||||||
const createdAt = now()
|
const createdAt = now()
|
||||||
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
@@ -1404,6 +1526,12 @@ module.exports = {
|
|||||||
getGameDetail,
|
getGameDetail,
|
||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
|
findImageAssetByHash,
|
||||||
|
createImageAsset,
|
||||||
|
createImageOptimizationJob,
|
||||||
|
findImageOptimizationJobById,
|
||||||
|
updateImageOptimizationJobStatus,
|
||||||
|
listRecentImageOptimizationJobs,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
const fs = require('fs/promises')
|
const fs = require('fs/promises')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const crypto = require('crypto')
|
||||||
const sharp = require('sharp')
|
const sharp = require('sharp')
|
||||||
const { nanoid } = require('nanoid')
|
const { nanoid } = require('nanoid')
|
||||||
|
const {
|
||||||
|
findImageAssetByHash,
|
||||||
|
createImageAsset,
|
||||||
|
createImageOptimizationJob,
|
||||||
|
updateImageOptimizationJobStatus,
|
||||||
|
} = require('../db')
|
||||||
|
|
||||||
const UPLOAD_ROOT = path.join(__dirname, '..', '..', 'uploads')
|
const UPLOAD_ROOT = path.join(__dirname, '..', '..', 'uploads')
|
||||||
|
const OPTIMIZED_DIR = 'assets'
|
||||||
|
const OPTIMIZATION_CONCURRENCY = Math.max(1, Number(process.env.IMAGE_OPTIMIZATION_CONCURRENCY || 1))
|
||||||
|
|
||||||
|
let activeCount = 0
|
||||||
|
const pendingJobs = []
|
||||||
|
|
||||||
function ensureImageMimeType(file) {
|
function ensureImageMimeType(file) {
|
||||||
return typeof file?.mimetype === 'string' && file.mimetype.startsWith('image/')
|
return typeof file?.mimetype === 'string' && file.mimetype.startsWith('image/')
|
||||||
@@ -23,6 +35,129 @@ function createMemoryUpload(multer, { fileSize = 6 * 1024 * 1024, maxCount } = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleQueue() {
|
||||||
|
while (activeCount < OPTIMIZATION_CONCURRENCY && pendingJobs.length) {
|
||||||
|
const job = pendingJobs.shift()
|
||||||
|
activeCount += 1
|
||||||
|
processQueuedJob(job)
|
||||||
|
.then(job.resolve)
|
||||||
|
.catch(job.reject)
|
||||||
|
.finally(() => {
|
||||||
|
activeCount = Math.max(0, activeCount - 1)
|
||||||
|
scheduleQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeAndPersist({ file, width, height, fit, quality }) {
|
||||||
|
const { data, info } = await sharp(file.buffer, { failOn: 'none' })
|
||||||
|
.rotate()
|
||||||
|
.resize({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fit,
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = String(Date.now()) + '-' + nanoid() + '.webp'
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processQueuedJob(job) {
|
||||||
|
await updateImageOptimizationJobStatus({
|
||||||
|
id: job.jobId,
|
||||||
|
status: 'processing',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await optimizeAndPersist(job)
|
||||||
|
await updateImageOptimizationJobStatus({
|
||||||
|
id: job.jobId,
|
||||||
|
status: 'completed',
|
||||||
|
optimizedByteSize: result.size,
|
||||||
|
reusedAsset: result.reused,
|
||||||
|
finishedAt: Date.now(),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
await updateImageOptimizationJobStatus({
|
||||||
|
id: job.jobId,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: error?.message || 'optimization_failed',
|
||||||
|
finishedAt: Date.now(),
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function writeOptimizedImage({
|
async function writeOptimizedImage({
|
||||||
file,
|
file,
|
||||||
directory,
|
directory,
|
||||||
@@ -43,29 +178,35 @@ async function writeOptimizedImage({
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, info } = await sharp(file.buffer, { failOn: 'none' })
|
const jobId = nanoid()
|
||||||
.rotate()
|
await createImageOptimizationJob({
|
||||||
.resize({
|
id: jobId,
|
||||||
|
sourceCategory: directory,
|
||||||
|
targetDirectory: OPTIMIZED_DIR,
|
||||||
|
originalByteSize: file.size || file.buffer.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingJobs.push({
|
||||||
|
jobId,
|
||||||
|
file,
|
||||||
|
directory,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
fit,
|
fit,
|
||||||
withoutEnlargement: true,
|
quality,
|
||||||
|
resolve: (result) => resolve({ ...result, directory }),
|
||||||
|
reject,
|
||||||
})
|
})
|
||||||
.webp({ quality })
|
scheduleQueue()
|
||||||
.toBuffer({ resolveWithObject: true })
|
})
|
||||||
|
}
|
||||||
const filename = String(Date.now()) + '-' + nanoid() + '.webp'
|
|
||||||
const absoluteDir = path.join(UPLOAD_ROOT, directory)
|
|
||||||
const absolutePath = path.join(absoluteDir, filename)
|
|
||||||
await fs.mkdir(absoluteDir, { recursive: true })
|
|
||||||
await fs.writeFile(absolutePath, data)
|
|
||||||
|
|
||||||
|
function getImageOptimizationQueueState() {
|
||||||
return {
|
return {
|
||||||
src: '/uploads/' + directory + '/' + filename,
|
concurrency: OPTIMIZATION_CONCURRENCY,
|
||||||
size: data.length,
|
activeCount,
|
||||||
originalSize: file.size || file.buffer.length,
|
pendingCount: pendingJobs.length,
|
||||||
width: info.width || 0,
|
|
||||||
height: info.height || 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,4 +214,5 @@ module.exports = {
|
|||||||
createMemoryUpload,
|
createMemoryUpload,
|
||||||
ensureImageMimeType,
|
ensureImageMimeType,
|
||||||
writeOptimizedImage,
|
writeOptimizedImage,
|
||||||
|
getImageOptimizationQueueState,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.2
|
||||||
|
- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함.
|
||||||
|
- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.
|
||||||
|
- 현재 라우트 응답 방식은 유지하면서도 내부적으로는 큐를 타도록 구조를 바꿔, 이후 관리자 대시보드와 작업 통계 화면을 바로 얹을 수 있는 기반을 마련함.
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.1
|
||||||
|
- 최적화된 WebP 결과물 기준으로 SHA-256 해시를 계산해, 같은 이미지가 다시 업로드되면 새 파일을 저장하지 않고 기존 자산을 재사용하도록 중복 이미지 해시 검사를 추가함.
|
||||||
|
- 이미지 자산 메타데이터를 저장하는 `image_assets` 테이블을 도입해 파일 경로, 해시, 원본 대비 최적화 용량, 해상도를 함께 기록하도록 확장함.
|
||||||
|
- 중복 업로드 경쟁 상황에서도 고유 해시 충돌을 안전하게 처리하고, 새 파일 저장에 실패하면 즉시 정리하도록 업로드 헬퍼를 보강함.
|
||||||
|
|
||||||
## 2026-03-31 v1.3.0
|
## 2026-03-31 v1.3.0
|
||||||
- 백엔드 업로드 파이프라인을 메모리 기반으로 전환하고, 대표 썸네일·게임 썸네일·커스텀 아이템·게임 기본 아이템·아바타를 서버에서 즉시 WebP로 변환해 저장하도록 정리함.
|
- 백엔드 업로드 파이프라인을 메모리 기반으로 전환하고, 대표 썸네일·게임 썸네일·커스텀 아이템·게임 기본 아이템·아바타를 서버에서 즉시 WebP로 변환해 저장하도록 정리함.
|
||||||
- 아이템 이미지는 최대 512px 규격으로 리사이즈하고, 티어표/게임 썸네일은 긴 변 기준 1280px 안쪽으로 최적화해 원본 이미지를 별도로 보관하지 않는 흐름으로 전환함.
|
- 아이템 이미지는 최대 512px 규격으로 리사이즈하고, 티어표/게임 썸네일은 긴 변 기준 1280px 안쪽으로 최적화해 원본 이미지를 별도로 보관하지 않는 흐름으로 전환함.
|
||||||
|
|||||||
Reference in New Issue
Block a user