Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7967361cac |
@@ -5,7 +5,8 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"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",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
@@ -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()
|
||||||
|
})
|
||||||
@@ -223,6 +223,14 @@ async function query(sql, params = []) {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function closePool() {
|
||||||
|
if (!poolPromise) return
|
||||||
|
const pool = await poolPromise
|
||||||
|
await pool.end()
|
||||||
|
poolPromise = null
|
||||||
|
initPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSchema() {
|
async function ensureSchema() {
|
||||||
if (initPromise) return initPromise
|
if (initPromise) return initPromise
|
||||||
initPromise = (async () => {
|
initPromise = (async () => {
|
||||||
@@ -618,6 +626,14 @@ async function findImageAssetByHash(contentHash) {
|
|||||||
return mapImageAssetRow(rows[0])
|
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 }) {
|
async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) {
|
||||||
const createdAt = now()
|
const createdAt = now()
|
||||||
await query(
|
await query(
|
||||||
@@ -1746,6 +1762,7 @@ async function unfavoriteGame({ userId, gameId }) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
DB_NAME,
|
DB_NAME,
|
||||||
ensureData,
|
ensureData,
|
||||||
|
closePool,
|
||||||
countUsers,
|
countUsers,
|
||||||
findUserByEmail,
|
findUserByEmail,
|
||||||
findUserById,
|
findUserById,
|
||||||
@@ -1762,6 +1779,7 @@ module.exports = {
|
|||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
findImageAssetByHash,
|
findImageAssetByHash,
|
||||||
|
findImageAssetBySrc,
|
||||||
createImageAsset,
|
createImageAsset,
|
||||||
createImageOptimizationJob,
|
createImageOptimizationJob,
|
||||||
findImageOptimizationJobById,
|
findImageOptimizationJobById,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
- 레거시 업로드 파일도 현재 실사용 용량에는 포함되지만, 과거 자산까지 'image_assets' 메타에 백필한 상태는 아니므로 필요하면 1회 백필 스크립트를 추가해 절감률/중복 통계를 완전히 일원화한다.
|
- 레거시 업로드 메타 백필은 가능해졌으므로, 필요하면 다음 단계에서 실제 파일 경로까지 `/uploads/assets/` 기준으로 재정렬하는 마이그레이션을 검토한다.
|
||||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||||
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.6
|
||||||
|
- 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함.
|
||||||
|
- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함.
|
||||||
|
- todo 문서의 즉시 확인 항목도 백필 완료 상태에 맞춰 후속 마이그레이션 과제로 갱신함.
|
||||||
|
|
||||||
## 2026-03-31 v1.3.5
|
## 2026-03-31 v1.3.5
|
||||||
- 관리자 이미지 최적화 대시보드는 이제 'image_assets'만이 아니라 현재 실제로 참조 중인 업로드 파일 전체를 합산해, 기존 레거시 업로드까지 포함한 실사용 용량을 함께 보여주도록 확장함.
|
- 관리자 이미지 최적화 대시보드는 이제 'image_assets'만이 아니라 현재 실제로 참조 중인 업로드 파일 전체를 합산해, 기존 레거시 업로드까지 포함한 실사용 용량을 함께 보여주도록 확장함.
|
||||||
- 최근 최적화 작업은 기본 12건으로 늘리고 6/12/24건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함.
|
- 최근 최적화 작업은 기본 12건으로 늘리고 6/12/24건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함.
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"dev:backend": "npm --prefix backend run dev",
|
"dev:backend": "npm --prefix backend run dev",
|
||||||
"build": "npm --prefix frontend run build",
|
"build": "npm --prefix frontend run build",
|
||||||
"start": "npm --prefix backend run start",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user