Compare commits

...

1 Commits

6 changed files with 220 additions and 15 deletions

View File

@@ -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": "",

View File

@@ -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()
})

View File

@@ -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,

View File

@@ -1,7 +1,7 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 레거시 업로드 메타 백필은 가능해졌으므로, 필요하면 다음 단계에서 실제 파일 경로까지 `/uploads/assets/` 기준으로 재정렬하는 마이그레이션 검토한다.
- 레거시 참조를 `/uploads/assets/`로 재정렬하는 마이그레이션 스크립트는 준비됐으므로, 운영 반영 후에는 더 이상 참조되지 않는 예전 업로드 파일을 안전하게 정리하는 후속 배치를 검토한다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.

View File

@@ -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회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함.
- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함.

View File

@@ -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": "",