diff --git a/backend/package.json b/backend/package.json index 0f5c338..d8a17a4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,8 @@ "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", - "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" + "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", + "images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js" }, "keywords": [], "author": "", diff --git a/backend/scripts/migrate-legacy-uploads-to-assets.js b/backend/scripts/migrate-legacy-uploads-to-assets.js new file mode 100644 index 0000000..e274b4f --- /dev/null +++ b/backend/scripts/migrate-legacy-uploads-to-assets.js @@ -0,0 +1,112 @@ +const fs = require('fs/promises') +const path = require('path') +const sharp = require('sharp') +const { + ensureData, + closePool, + listReferencedUploadUsage, + replaceUploadSourceReferences, +} = require('../src/db') +const { writeOptimizedImage } = require('../src/lib/image-storage') + +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' +} + +function getOptimizationConfig(roles) { + const roleSet = new Set(roles || []) + if (roleSet.has('avatar')) { + return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 } + } + if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) { + return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 } + } + return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 } +} + +async function createFileLike(src) { + const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, '')) + 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 = {} + } + return { + file: { + originalname: path.basename(src), + mimetype: inferMimeType(src, metadata), + size: Number(stat.size || 0), + buffer, + }, + absolutePath, + } +} + +async function main() { + await ensureData() + const usageEntries = await listReferencedUploadUsage() + const legacyEntries = usageEntries.filter((entry) => entry.src && entry.src.startsWith('/uploads/') && !entry.src.startsWith('/uploads/assets/')) + + const summary = { + scanned: legacyEntries.length, + migrated: 0, + reusedAsset: 0, + unchanged: 0, + missingFiles: 0, + failed: 0, + updatedRows: 0, + } + + for (const entry of legacyEntries) { + const config = getOptimizationConfig(entry.roles) + try { + const { file } = await createFileLike(entry.src) + const optimized = await writeOptimizedImage({ file, ...config }) + if (optimized.src === entry.src) { + summary.unchanged += 1 + continue + } + const replaced = await replaceUploadSourceReferences({ fromSrc: entry.src, toSrc: optimized.src }) + summary.updatedRows += Number(replaced.updatedRows || 0) + if (optimized.reused) summary.reusedAsset += 1 + else summary.migrated += 1 + } catch (error) { + if (error?.code === 'ENOENT') { + summary.missingFiles += 1 + continue + } + summary.failed += 1 + console.error('[migrate-legacy-uploads-to-assets] failed:', entry.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 4e4f2ab..7eba86c 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -754,32 +754,116 @@ async function deleteImageAssets(ids) { } async function listReferencedUploadSources() { - const referencedSrcs = new Set() + const usage = await listReferencedUploadUsage() + return usage.map((entry) => entry.src) +} + +async function listReferencedUploadUsage() { + const usageMap = new Map() + const addUsage = (src, role) => { + if (typeof src !== 'string' || !src.startsWith('/uploads/')) return + if (!usageMap.has(src)) usageMap.set(src, new Set()) + usageMap.get(src).add(role) + } + const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ query("SELECT avatar_src FROM users WHERE avatar_src <> ''"), query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"), query("SELECT src FROM game_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"), - query("SELECT thumbnail_src, pool_json FROM tierlists"), - query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"), + query("SELECT id, thumbnail_src, pool_json FROM tierlists"), + query("SELECT id, thumbnail_src_snapshot, items_json FROM template_requests"), ]) - for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src) - for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src) - for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src) - for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src) + for (const row of userRows) addUsage(row.avatar_src, 'avatar') + for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail') + for (const row of gameItemRows) addUsage(row.src, 'game-item') + for (const row of customItemRows) addUsage(row.src, 'custom-item') for (const row of tierListRows) { - if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src) - collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs) + addUsage(row.thumbnail_src, 'tierlist-thumbnail') + for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool') } for (const row of templateRequestRows) { - if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot) - collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs) + addUsage(row.thumbnail_src_snapshot, 'template-thumbnail') + for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item') } - return Array.from(referencedSrcs) + return Array.from(usageMap.entries()) + .map(([src, roles]) => ({ src, roles: Array.from(roles).sort() })) + .sort((a, b) => a.src.localeCompare(b.src)) +} + +function replaceItemSrc(items, fromSrc, toSrc) { + let changed = false + const nextItems = (items || []).map((item) => { + if (item?.src !== fromSrc) return item + changed = true + return { ...item, src: toSrc } + }) + return { changed, items: nextItems } +} + +async function replaceUploadSourceReferences({ fromSrc, toSrc }) { + if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 } + + const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([ + query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]), + query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]), + query('UPDATE game_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), + query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), + ]) + + let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0) + + const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists') + for (const row of tierListRows) { + let nextThumbnail = row.thumbnail_src + let changed = false + if (row.thumbnail_src === fromSrc) { + nextThumbnail = toSrc + changed = true + } + + const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc) + if (replacedPool.changed) changed = true + + if (changed) { + await query('UPDATE tierlists SET thumbnail_src = ?, pool_json = ?, updated_at = ? WHERE id = ?', [ + nextThumbnail || '', + serializeJson(replacedPool.items), + now(), + row.id, + ]) + updatedRows += 1 + } + } + + const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json FROM template_requests') + for (const row of requestRows) { + let nextThumbnail = row.thumbnail_src_snapshot + let changed = false + if (row.thumbnail_src_snapshot === fromSrc) { + nextThumbnail = toSrc + changed = true + } + + const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc) + if (replacedItems.changed) changed = true + + if (changed) { + await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, updated_at = ? WHERE id = ?', [ + nextThumbnail || '', + serializeJson(replacedItems.items), + now(), + row.id, + ]) + updatedRows += 1 + } + } + + return { updatedRows } } async function listImageAssets() { @@ -1788,6 +1872,8 @@ module.exports = { listUnusedImageAssets, deleteImageAssets, listReferencedUploadSources, + listReferencedUploadUsage, + replaceUploadSourceReferences, clearImageOptimizationJobs, getImageAssetStats, createGameItem, diff --git a/docs/todo.md b/docs/todo.md index 4eaa696..6f43c3d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,7 +1,7 @@ # 할 일 및 이슈 ## 즉시 확인 필요 -- 레거시 업로드 메타 백필은 가능해졌으므로, 필요하면 다음 단계에서 실제 파일 경로까지 `/uploads/assets/` 기준으로 재정렬하는 마이그레이션을 검토한다. +- 레거시 참조를 `/uploads/assets/`로 재정렬하는 마이그레이션 스크립트는 준비됐으므로, 운영 반영 후에는 더 이상 참조되지 않는 예전 업로드 파일을 안전하게 정리하는 후속 배치를 검토한다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. - 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다. diff --git a/docs/update.md b/docs/update.md index c6ed6fa..999cfa3 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.3.7 +- 현재 참조 중인 레거시 업로드를 다시 최적화 자산 경로로 편입하고 DB 참조를 일괄 교체하는 1회 마이그레이션 스크립트를 추가함. +- 아바타/썸네일/아이템 역할에 따라 기존 업로드를 512px 또는 1280px 규격으로 다시 정리해, 실제 참조 경로도 '/uploads/assets/' 체계에 점진적으로 수렴시킬 수 있게 함. +- 루트와 백엔드에 레거시 마이그레이션 실행 스크립트를 연결하고, todo 문서도 다음 단계 기준으로 갱신함. + ## 2026-03-31 v1.3.6 - 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함. - 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함. diff --git a/package.json b/package.json index 0929fad..21ae87a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "npm --prefix frontend run build", "start": "npm --prefix backend run start", "test": "echo \"Error: no test specified\" && exit 1", - "images:backfill": "npm --prefix backend run images:backfill" + "images:backfill": "npm --prefix backend run images:backfill", + "images:migrate-legacy": "npm --prefix backend run images:migrate-legacy" }, "keywords": [], "author": "",