Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a19606c516 |
@@ -28,6 +28,14 @@ function serializeJson(value) {
|
|||||||
return JSON.stringify(value || [])
|
return JSON.stringify(value || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectUploadSrcsFromItems(items, bucket) {
|
||||||
|
for (const item of items || []) {
|
||||||
|
if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
|
||||||
|
bucket.add(item.src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mapUserRow(row) {
|
function mapUserRow(row) {
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
return {
|
return {
|
||||||
@@ -644,6 +652,59 @@ async function listRecentImageOptimizationJobs(limit = 20) {
|
|||||||
)
|
)
|
||||||
return rows.map(mapImageOptimizationJobRow)
|
return rows.map(mapImageOptimizationJobRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
|
||||||
|
const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100))
|
||||||
|
const safeMinAgeHours = Math.max(0, Number(minAgeHours) || 24)
|
||||||
|
const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const assets = (await query(
|
||||||
|
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
|
||||||
|
[cutoff]
|
||||||
|
)).map(mapImageAssetRow)
|
||||||
|
|
||||||
|
if (!assets.length) return []
|
||||||
|
|
||||||
|
const referencedSrcs = new Set()
|
||||||
|
|
||||||
|
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"),
|
||||||
|
])
|
||||||
|
|
||||||
|
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 tierListRows) {
|
||||||
|
if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
||||||
|
collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of templateRequestRows) {
|
||||||
|
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
|
||||||
|
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets.filter((asset) => !referencedSrcs.has(asset.src))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteImageAssets(ids) {
|
||||||
|
const uniqueIds = Array.from(new Set((ids || []).filter(Boolean)))
|
||||||
|
if (!uniqueIds.length) return []
|
||||||
|
const placeholders = uniqueIds.map(() => '?').join(', ')
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
|
||||||
|
uniqueIds
|
||||||
|
)
|
||||||
|
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
|
||||||
|
return rows.map(mapImageAssetRow)
|
||||||
|
}
|
||||||
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 (?, ?, ?, ?, ?)', [
|
||||||
@@ -1532,6 +1593,8 @@ module.exports = {
|
|||||||
findImageOptimizationJobById,
|
findImageOptimizationJobById,
|
||||||
updateImageOptimizationJobStatus,
|
updateImageOptimizationJobStatus,
|
||||||
listRecentImageOptimizationJobs,
|
listRecentImageOptimizationJobs,
|
||||||
|
listUnusedImageAssets,
|
||||||
|
deleteImageAssets,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const {
|
|||||||
adminUpdateUser,
|
adminUpdateUser,
|
||||||
adminUpdateUserPassword,
|
adminUpdateUserPassword,
|
||||||
adminDeleteUser,
|
adminDeleteUser,
|
||||||
|
listUnusedImageAssets,
|
||||||
|
deleteImageAssets,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAdmin } = require('../middleware/auth')
|
const { requireAdmin } = require('../middleware/auth')
|
||||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||||
@@ -206,6 +208,46 @@ router.get('/template-requests', requireAdmin, async (req, res) => {
|
|||||||
res.json({ requests })
|
res.json({ requests })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/image-assets/orphans', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
|
||||||
|
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.query)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const assets = await listUnusedImageAssets(parsed.data)
|
||||||
|
res.json({ assets })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function removeImageAssetFiles(assets) {
|
||||||
|
await Promise.all(
|
||||||
|
(assets || []).map(async (asset) => {
|
||||||
|
if (!asset?.src || !asset.src.startsWith('/uploads/')) return
|
||||||
|
const absolutePath = path.join(__dirname, '..', '..', asset.src.replace(/^\//, ''))
|
||||||
|
try {
|
||||||
|
await fs.unlink(absolutePath)
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/image-assets/cleanup', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
|
||||||
|
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const assets = await listUnusedImageAssets(parsed.data)
|
||||||
|
const deleted = await deleteImageAssets(assets.map((asset) => asset.id))
|
||||||
|
await removeImageAssetFiles(deleted)
|
||||||
|
res.json({ deletedCount: deleted.length, assets: deleted })
|
||||||
|
})
|
||||||
|
|
||||||
async function removeCustomItemFiles(items) {
|
async function removeCustomItemFiles(items) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
@@ -221,33 +263,18 @@ async function removeCustomItemFiles(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function promoteCustomItemToGameItem({ customItem, gameId }) {
|
async function promoteCustomItemToGameItem({ customItem, gameId }) {
|
||||||
const originalName = path.basename(customItem.src || '')
|
|
||||||
const nextFilename = buildUploadFilename({ originalname: originalName })
|
|
||||||
const sourcePath = path.join(__dirname, '..', '..', customItem.src.replace(/^\//, ''))
|
|
||||||
const targetRelativePath = path.join('uploads', 'games', nextFilename)
|
|
||||||
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
|
|
||||||
|
|
||||||
await fs.copyFile(sourcePath, targetPath)
|
|
||||||
|
|
||||||
return createGameItem({
|
return createGameItem({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
gameId,
|
gameId,
|
||||||
src: `/${targetRelativePath.replace(/\\/g, '/')}`,
|
src: customItem.src || '',
|
||||||
label: customItem.label,
|
label: customItem.label,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyUploadIntoGameAsset(src) {
|
async function copyUploadIntoGameAsset(src) {
|
||||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
|
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
|
||||||
|
if (src.startsWith('/uploads/assets/')) return src
|
||||||
const originalName = path.basename(src)
|
return src
|
||||||
const nextFilename = buildUploadFilename({ originalname: originalName })
|
|
||||||
const sourcePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
|
|
||||||
const targetRelativePath = path.join('uploads', 'games', nextFilename)
|
|
||||||
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
|
|
||||||
|
|
||||||
await fs.copyFile(sourcePath, targetPath)
|
|
||||||
return `/${targetRelativePath.replace(/\\/g, '/')}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueTierListPoolItems(tierList) {
|
function uniqueTierListPoolItems(tierList) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.3
|
||||||
|
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
|
||||||
|
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
|
||||||
|
- 관리자 승격/템플릿 생성 과정은 기존 `/uploads/assets/` 자산을 그대로 재사용하도록 바꿔, 불필요한 복제 파일이 다시 생기지 않게 정리함.
|
||||||
|
|
||||||
## 2026-03-31 v1.3.2
|
## 2026-03-31 v1.3.2
|
||||||
- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함.
|
- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함.
|
||||||
- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.
|
- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.
|
||||||
|
|||||||
Reference in New Issue
Block a user