이미지 최적화 작업 큐 추가
This commit is contained in:
@@ -78,6 +78,23 @@ function mapImageAssetRow(row) {
|
|||||||
createdAt: Number(row.created_at || 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 {
|
||||||
@@ -298,6 +315,24 @@ async function ensureSchema() {
|
|||||||
INDEX idx_image_assets_created_at (created_at)
|
INDEX idx_image_assets_created_at (created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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,
|
||||||
@@ -566,6 +601,49 @@ async function createImageAsset({ id, contentHash, src, mimeType = "image/webp",
|
|||||||
)
|
)
|
||||||
return findImageAssetByHash(contentHash)
|
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 (?, ?, ?, ?, ?)', [
|
||||||
@@ -1450,6 +1528,10 @@ module.exports = {
|
|||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
findImageAssetByHash,
|
findImageAssetByHash,
|
||||||
createImageAsset,
|
createImageAsset,
|
||||||
|
createImageOptimizationJob,
|
||||||
|
findImageOptimizationJobById,
|
||||||
|
updateImageOptimizationJobStatus,
|
||||||
|
listRecentImageOptimizationJobs,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ const path = require('path')
|
|||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const sharp = require('sharp')
|
const sharp = require('sharp')
|
||||||
const { nanoid } = require('nanoid')
|
const { nanoid } = require('nanoid')
|
||||||
const { findImageAssetByHash, createImageAsset } = require('../db')
|
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 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/')
|
||||||
@@ -26,26 +35,21 @@ function createMemoryUpload(multer, { fileSize = 6 * 1024 * 1024, maxCount } = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeOptimizedImage({
|
function scheduleQueue() {
|
||||||
file,
|
while (activeCount < OPTIMIZATION_CONCURRENCY && pendingJobs.length) {
|
||||||
directory,
|
const job = pendingJobs.shift()
|
||||||
width,
|
activeCount += 1
|
||||||
height,
|
processQueuedJob(job)
|
||||||
fit = 'inside',
|
.then(job.resolve)
|
||||||
quality = 82,
|
.catch(job.reject)
|
||||||
}) {
|
.finally(() => {
|
||||||
if (!file?.buffer?.length) {
|
activeCount = Math.max(0, activeCount - 1)
|
||||||
const error = new Error('file_required')
|
scheduleQueue()
|
||||||
error.code = 'file_required'
|
})
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ensureImageMimeType(file)) {
|
|
||||||
const error = new Error('image_file_required')
|
|
||||||
error.code = 'image_file_required'
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeAndPersist({ file, width, height, fit, quality }) {
|
||||||
const { data, info } = await sharp(file.buffer, { failOn: 'none' })
|
const { data, info } = await sharp(file.buffer, { failOn: 'none' })
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize({
|
.resize({
|
||||||
@@ -68,7 +72,6 @@ async function writeOptimizedImage({
|
|||||||
height: existing.height,
|
height: existing.height,
|
||||||
contentHash: existing.contentHash,
|
contentHash: existing.contentHash,
|
||||||
reused: true,
|
reused: true,
|
||||||
directory,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +103,6 @@ async function writeOptimizedImage({
|
|||||||
height: asset.height,
|
height: asset.height,
|
||||||
contentHash: asset.contentHash,
|
contentHash: asset.contentHash,
|
||||||
reused: false,
|
reused: false,
|
||||||
directory,
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
@@ -120,7 +122,6 @@ async function writeOptimizedImage({
|
|||||||
height: asset.height,
|
height: asset.height,
|
||||||
contentHash: asset.contentHash,
|
contentHash: asset.contentHash,
|
||||||
reused: true,
|
reused: true,
|
||||||
directory,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,8 +130,89 @@ async function writeOptimizedImage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
file,
|
||||||
|
directory,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fit = 'inside',
|
||||||
|
quality = 82,
|
||||||
|
}) {
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
const error = new Error('file_required')
|
||||||
|
error.code = 'file_required'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureImageMimeType(file)) {
|
||||||
|
const error = new Error('image_file_required')
|
||||||
|
error.code = 'image_file_required'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = nanoid()
|
||||||
|
await createImageOptimizationJob({
|
||||||
|
id: jobId,
|
||||||
|
sourceCategory: directory,
|
||||||
|
targetDirectory: OPTIMIZED_DIR,
|
||||||
|
originalByteSize: file.size || file.buffer.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingJobs.push({
|
||||||
|
jobId,
|
||||||
|
file,
|
||||||
|
directory,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fit,
|
||||||
|
quality,
|
||||||
|
resolve: (result) => resolve({ ...result, directory }),
|
||||||
|
reject,
|
||||||
|
})
|
||||||
|
scheduleQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageOptimizationQueueState() {
|
||||||
|
return {
|
||||||
|
concurrency: OPTIMIZATION_CONCURRENCY,
|
||||||
|
activeCount,
|
||||||
|
pendingCount: pendingJobs.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createMemoryUpload,
|
createMemoryUpload,
|
||||||
ensureImageMimeType,
|
ensureImageMimeType,
|
||||||
writeOptimizedImage,
|
writeOptimizedImage,
|
||||||
|
getImageOptimizationQueueState,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.2
|
||||||
|
- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함.
|
||||||
|
- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.
|
||||||
|
- 현재 라우트 응답 방식은 유지하면서도 내부적으로는 큐를 타도록 구조를 바꿔, 이후 관리자 대시보드와 작업 통계 화면을 바로 얹을 수 있는 기반을 마련함.
|
||||||
|
|
||||||
## 2026-03-31 v1.3.1
|
## 2026-03-31 v1.3.1
|
||||||
- 최적화된 WebP 결과물 기준으로 SHA-256 해시를 계산해, 같은 이미지가 다시 업로드되면 새 파일을 저장하지 않고 기존 자산을 재사용하도록 중복 이미지 해시 검사를 추가함.
|
- 최적화된 WebP 결과물 기준으로 SHA-256 해시를 계산해, 같은 이미지가 다시 업로드되면 새 파일을 저장하지 않고 기존 자산을 재사용하도록 중복 이미지 해시 검사를 추가함.
|
||||||
- 이미지 자산 메타데이터를 저장하는 `image_assets` 테이블을 도입해 파일 경로, 해시, 원본 대비 최적화 용량, 해상도를 함께 기록하도록 확장함.
|
- 이미지 자산 메타데이터를 저장하는 `image_assets` 테이블을 도입해 파일 경로, 해시, 원본 대비 최적화 용량, 해상도를 함께 기록하도록 확장함.
|
||||||
|
|||||||
Reference in New Issue
Block a user