릴리스: v1.3.5 이미지 최적화 대시보드 기간 필터와 실사용 통계
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
@@ -36,6 +38,21 @@ function collectUploadSrcsFromItems(items, bucket) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMonthRange(month) {
|
||||
if (typeof month !== 'string') return null
|
||||
const match = month.trim().match(/^(\d{4})-(\d{2})$/)
|
||||
if (!match) return null
|
||||
|
||||
const year = Number(match[1])
|
||||
const monthIndex = Number(match[2]) - 1
|
||||
if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null
|
||||
|
||||
return {
|
||||
start: new Date(year, monthIndex, 1).getTime(),
|
||||
end: new Date(year, monthIndex + 1, 1).getTime(),
|
||||
}
|
||||
}
|
||||
|
||||
function mapUserRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
@@ -645,10 +662,24 @@ async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize
|
||||
return findImageOptimizationJobById(id)
|
||||
}
|
||||
|
||||
async function listRecentImageOptimizationJobs(limit = 20) {
|
||||
async function listRecentImageOptimizationJobs(limit = 20, { month } = {}) {
|
||||
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20))
|
||||
const range = resolveMonthRange(month)
|
||||
const where = []
|
||||
const params = []
|
||||
|
||||
if (range) {
|
||||
where.push('queued_at >= ? AND queued_at < ?')
|
||||
params.push(range.start, range.end)
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
`SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs ORDER BY queued_at DESC LIMIT ${safeLimit}`
|
||||
`SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at
|
||||
FROM image_optimization_jobs
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY queued_at DESC
|
||||
LIMIT ${safeLimit}`,
|
||||
params
|
||||
)
|
||||
return rows.map(mapImageOptimizationJobRow)
|
||||
}
|
||||
@@ -706,8 +737,94 @@ async function deleteImageAssets(ids) {
|
||||
return rows.map(mapImageAssetRow)
|
||||
}
|
||||
|
||||
async function getImageAssetStats() {
|
||||
const [assetRows, jobRows] = await Promise.all([
|
||||
async function listReferencedUploadSources() {
|
||||
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 Array.from(referencedSrcs)
|
||||
}
|
||||
|
||||
async function listImageAssets() {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
|
||||
)
|
||||
return rows.map(mapImageAssetRow)
|
||||
}
|
||||
|
||||
async function getReferencedUploadFootprint() {
|
||||
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
|
||||
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
|
||||
let totalReferencedByteSize = 0
|
||||
let trackedReferencedByteSize = 0
|
||||
let legacyReferencedByteSize = 0
|
||||
let trackedReferencedCount = 0
|
||||
let legacyReferencedCount = 0
|
||||
let missingCount = 0
|
||||
|
||||
for (const src of referencedSrcs) {
|
||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) continue
|
||||
const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(absolutePath)
|
||||
const size = Number(stat.size || 0)
|
||||
totalReferencedByteSize += size
|
||||
if (assetMap.has(src)) {
|
||||
trackedReferencedCount += 1
|
||||
trackedReferencedByteSize += size
|
||||
} else {
|
||||
legacyReferencedCount += 1
|
||||
legacyReferencedByteSize += size
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') missingCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
referencedCount: referencedSrcs.length,
|
||||
totalReferencedByteSize,
|
||||
trackedReferencedCount,
|
||||
trackedReferencedByteSize,
|
||||
legacyReferencedCount,
|
||||
legacyReferencedByteSize,
|
||||
missingCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function getImageAssetStats({ month } = {}) {
|
||||
const range = resolveMonthRange(month)
|
||||
const jobWhere = []
|
||||
const jobParams = []
|
||||
|
||||
if (range) {
|
||||
jobWhere.push('queued_at >= ? AND queued_at < ?')
|
||||
jobParams.push(range.start, range.end)
|
||||
}
|
||||
|
||||
const [assetRows, jobRows, footprint] = await Promise.all([
|
||||
query(
|
||||
`SELECT COUNT(*) AS asset_count, COALESCE(SUM(byte_size), 0) AS total_byte_size, COALESCE(SUM(original_byte_size), 0) AS total_original_byte_size FROM image_assets`
|
||||
),
|
||||
@@ -718,8 +835,11 @@ async function getImageAssetStats() {
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' AND reused_asset = 1 THEN 1 ELSE 0 END), 0) AS reused_count
|
||||
FROM image_optimization_jobs`
|
||||
FROM image_optimization_jobs
|
||||
${jobWhere.length ? `WHERE ${jobWhere.join(' AND ')}` : ''}`,
|
||||
jobParams
|
||||
),
|
||||
getReferencedUploadFootprint(),
|
||||
])
|
||||
|
||||
const asset = assetRows[0] || {}
|
||||
@@ -734,6 +854,13 @@ async function getImageAssetStats() {
|
||||
totalOriginalByteSize,
|
||||
savedByteSize,
|
||||
savingsRatio: totalOriginalByteSize > 0 ? savedByteSize / totalOriginalByteSize : 0,
|
||||
referencedCount: Number(footprint.referencedCount || 0),
|
||||
referencedByteSize: Number(footprint.totalReferencedByteSize || 0),
|
||||
trackedReferencedCount: Number(footprint.trackedReferencedCount || 0),
|
||||
trackedReferencedByteSize: Number(footprint.trackedReferencedByteSize || 0),
|
||||
legacyReferencedCount: Number(footprint.legacyReferencedCount || 0),
|
||||
legacyReferencedByteSize: Number(footprint.legacyReferencedByteSize || 0),
|
||||
missingReferencedCount: Number(footprint.missingCount || 0),
|
||||
queuedCount: Number(jobs.queued_count || 0),
|
||||
processingCount: Number(jobs.processing_count || 0),
|
||||
completedCount: Number(jobs.completed_count || 0),
|
||||
@@ -741,6 +868,17 @@ async function getImageAssetStats() {
|
||||
reusedCount: Number(jobs.reused_count || 0),
|
||||
}
|
||||
}
|
||||
|
||||
async function clearImageOptimizationJobs({ month } = {}) {
|
||||
const range = resolveMonthRange(month)
|
||||
if (range) {
|
||||
const result = await query('DELETE FROM image_optimization_jobs WHERE queued_at >= ? AND queued_at < ?', [range.start, range.end])
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
|
||||
const result = await query('DELETE FROM image_optimization_jobs')
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
async function createGameItem({ id, gameId, src, label }) {
|
||||
const createdAt = now()
|
||||
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||
@@ -1631,6 +1769,8 @@ module.exports = {
|
||||
listRecentImageOptimizationJobs,
|
||||
listUnusedImageAssets,
|
||||
deleteImageAssets,
|
||||
listReferencedUploadSources,
|
||||
clearImageOptimizationJobs,
|
||||
getImageAssetStats,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
deleteImageAssets,
|
||||
getImageAssetStats,
|
||||
listRecentImageOptimizationJobs,
|
||||
clearImageOptimizationJobs,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
@@ -251,17 +252,37 @@ router.post('/image-assets/cleanup', requireAdmin, async (req, res) => {
|
||||
})
|
||||
|
||||
router.get('/image-assets/stats', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(24).optional().default(12),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const filters = { month: parsed.data.month }
|
||||
const [stats, recentJobs] = await Promise.all([
|
||||
getImageAssetStats(),
|
||||
listRecentImageOptimizationJobs(6),
|
||||
getImageAssetStats(filters),
|
||||
listRecentImageOptimizationJobs(parsed.data.limit, filters),
|
||||
])
|
||||
res.json({
|
||||
stats,
|
||||
filters,
|
||||
queue: getImageOptimizationQueueState(),
|
||||
recentJobs,
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional().nullable(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body || {})
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const deletedCount = await clearImageOptimizationJobs({ month: parsed.data.month || undefined })
|
||||
res.json({ deletedCount })
|
||||
})
|
||||
|
||||
async function removeCustomItemFiles(items) {
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
|
||||
Reference in New Issue
Block a user