From 7967361cac54bb346b9a28175f64320f4284d972 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 31 Mar 2026 18:37:51 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.3.6=20?= =?UTF-8?q?=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=20=EB=B0=B1=ED=95=84=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 3 +- .../scripts/backfill-legacy-image-assets.js | 106 ++++++++++++++++++ backend/src/db.js | 18 +++ docs/todo.md | 2 +- docs/update.md | 5 + package.json | 3 +- 6 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 backend/scripts/backfill-legacy-image-assets.js diff --git a/backend/package.json b/backend/package.json index 4dc996e..0f5c338 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js", - "start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js" + "start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js", + "images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js" }, "keywords": [], "author": "", diff --git a/backend/scripts/backfill-legacy-image-assets.js b/backend/scripts/backfill-legacy-image-assets.js new file mode 100644 index 0000000..33db14e --- /dev/null +++ b/backend/scripts/backfill-legacy-image-assets.js @@ -0,0 +1,106 @@ +const fs = require('fs/promises') +const path = require('path') +const crypto = require('crypto') +const sharp = require('sharp') +const { nanoid } = require('nanoid') +const { + ensureData, + closePool, + listReferencedUploadSources, + findImageAssetBySrc, + createImageAsset, +} = require('../src/db') + +const BACKEND_ROOT = path.join(__dirname, '..') + +function inferMimeType(src, metadata) { + const format = String(metadata?.format || '').toLowerCase() + if (format === 'jpeg' || format === 'jpg') return 'image/jpeg' + if (format === 'png') return 'image/png' + if (format === 'gif') return 'image/gif' + if (format === 'webp') return 'image/webp' + if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml' + if (format === 'avif') return 'image/avif' + + const ext = path.extname(src || '').toLowerCase() + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' + if (ext === '.png') return 'image/png' + if (ext === '.gif') return 'image/gif' + if (ext === '.webp') return 'image/webp' + if (ext === '.svg') return 'image/svg+xml' + if (ext === '.avif') return 'image/avif' + return 'application/octet-stream' +} + +async function main() { + await ensureData() + + const referencedSrcs = Array.from(new Set(await listReferencedUploadSources())) + .filter((src) => typeof src === 'string' && src.startsWith('/uploads/')) + .sort() + + const summary = { + scanned: referencedSrcs.length, + skippedExisting: 0, + backfilled: 0, + missingFiles: 0, + failed: 0, + } + + for (const src of referencedSrcs) { + const existing = await findImageAssetBySrc(src) + if (existing) { + summary.skippedExisting += 1 + continue + } + + const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, '')) + + try { + const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)]) + let metadata = {} + try { + metadata = await sharp(buffer, { failOn: 'none' }).metadata() + } catch (error) { + metadata = {} + } + + const rawHash = crypto.createHash('sha256').update(buffer).digest('hex') + const contentHash = crypto.createHash('sha256').update(`${rawHash}|${src}`).digest('hex') + + await createImageAsset({ + id: nanoid(), + contentHash, + src, + mimeType: inferMimeType(src, metadata), + byteSize: Number(stat.size || 0), + originalByteSize: Number(stat.size || 0), + width: Number(metadata.width || 0), + height: Number(metadata.height || 0), + }) + summary.backfilled += 1 + } catch (error) { + if (error?.code === 'ENOENT') { + summary.missingFiles += 1 + continue + } + if (error?.code === 'ER_DUP_ENTRY') { + summary.skippedExisting += 1 + continue + } + summary.failed += 1 + console.error('[backfill-legacy-image-assets] failed:', src, error?.message || error) + } + } + + console.log(JSON.stringify(summary, null, 2)) +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await closePool() + }) diff --git a/backend/src/db.js b/backend/src/db.js index 1248d4a..4e4f2ab 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -223,6 +223,14 @@ async function query(sql, params = []) { return rows } +async function closePool() { + if (!poolPromise) return + const pool = await poolPromise + await pool.end() + poolPromise = null + initPromise = null +} + async function ensureSchema() { if (initPromise) return initPromise initPromise = (async () => { @@ -618,6 +626,14 @@ async function findImageAssetByHash(contentHash) { return mapImageAssetRow(rows[0]) } +async function findImageAssetBySrc(src) { + const rows = await query( + 'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1', + [src] + ) + return mapImageAssetRow(rows[0]) +} + async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) { const createdAt = now() await query( @@ -1746,6 +1762,7 @@ async function unfavoriteGame({ userId, gameId }) { module.exports = { DB_NAME, ensureData, + closePool, countUsers, findUserByEmail, findUserById, @@ -1762,6 +1779,7 @@ module.exports = { createGame, updateGameThumbnail, findImageAssetByHash, + findImageAssetBySrc, createImageAsset, createImageOptimizationJob, findImageOptimizationJobById, diff --git a/docs/todo.md b/docs/todo.md index 2f47970..4eaa696 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,7 +1,7 @@ # 할 일 및 이슈 ## 즉시 확인 필요 -- 레거시 업로드 파일도 현재 실사용 용량에는 포함되지만, 과거 자산까지 'image_assets' 메타에 백필한 상태는 아니므로 필요하면 1회 백필 스크립트를 추가해 절감률/중복 통계를 완전히 일원화한다. +- 레거시 업로드 메타 백필은 가능해졌으므로, 필요하면 다음 단계에서 실제 파일 경로까지 `/uploads/assets/` 기준으로 재정렬하는 마이그레이션을 검토한다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. - 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다. diff --git a/docs/update.md b/docs/update.md index 25a4809..c6ed6fa 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.3.6 +- 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함. +- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함. +- todo 문서의 즉시 확인 항목도 백필 완료 상태에 맞춰 후속 마이그레이션 과제로 갱신함. + ## 2026-03-31 v1.3.5 - 관리자 이미지 최적화 대시보드는 이제 'image_assets'만이 아니라 현재 실제로 참조 중인 업로드 파일 전체를 합산해, 기존 레거시 업로드까지 포함한 실사용 용량을 함께 보여주도록 확장함. - 최근 최적화 작업은 기본 12건으로 늘리고 6/12/24건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함. diff --git a/package.json b/package.json index ae8c3a3..0929fad 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev:backend": "npm --prefix backend run dev", "build": "npm --prefix frontend run build", "start": "npm --prefix backend run start", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "images:backfill": "npm --prefix backend run images:backfill" }, "keywords": [], "author": "",