릴리스: v1.3.59 관리자 템플릿 요청 중복 방지 및 신규 템플릿 연결 흐름 정리

This commit is contained in:
2026-04-02 12:46:59 +09:00
parent 4f300e7dbc
commit aa114a170e
11 changed files with 303 additions and 32 deletions

View File

@@ -2004,6 +2004,11 @@ async function updateTemplateRequestStatus({ id, status }) {
return findTemplateRequestById(id)
}
async function updateTemplateRequestTargetGame({ id, targetGameId }) {
await query('UPDATE template_requests SET target_game_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id])
return findTemplateRequestById(id)
}
async function deleteTierList(id) {
await query('DELETE FROM tierlists WHERE id = ?', [id])
}
@@ -2184,4 +2189,5 @@ module.exports = {
findTemplateRequestById,
listAdminTemplateRequests,
updateTemplateRequestStatus,
updateTemplateRequestTargetGame,
}

View File

@@ -9,6 +9,7 @@ const {
findUserById,
findGameById,
findGameItemById,
listGameItems,
findImageAssetById,
createGame,
listGames,
@@ -33,6 +34,7 @@ const {
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
updateTemplateRequestTargetGame,
adminUpdateUser,
adminUpdateUserPassword,
adminDeleteUser,
@@ -65,6 +67,17 @@ function buildItemLabelFromFilename(file) {
return normalized || 'item'
}
function buildItemLabelFromSrc(src) {
const raw = typeof src === 'string' ? src : ''
const base = path.basename(raw.split('?')[0] || '', path.extname(raw.split('?')[0] || ''))
const normalized = base
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 60)
return normalized || 'item'
}
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
@@ -399,9 +412,25 @@ async function promoteLibraryItemToGameItem({ item, gameId }) {
}
async function copyUploadIntoGameAsset(src) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
if (src.startsWith('/uploads/assets/')) return src
return src
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
if (raw.startsWith('/uploads/')) {
if (raw.startsWith('/uploads/assets/')) return raw
return raw
}
try {
const url = new URL(raw)
if (url.pathname.startsWith('/uploads/')) {
return url.pathname
}
} catch (error) {
return raw
}
return raw
}
function uniqueTierListPoolItems(tierList) {
@@ -435,10 +464,17 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
}
async function promoteSnapshotItemsToGame({ items, gameId }) {
const existingItems = await listGameItems(gameId)
const existingSrcs = new Set(
existingItems
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
.filter(Boolean)
)
const createdItems = []
for (const item of items || []) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
createdItems.push(
await createGameItem({
id: nanoid(),
@@ -447,19 +483,36 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
label: item.label,
})
)
existingSrcs.add(copiedSrc)
}
return createdItems
}
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}) {
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}, itemSrcs = []) {
const requestedIds = new Set((itemIds || []).filter(Boolean))
const requestedSrcs = new Set((itemSrcs || []).filter((src) => typeof src === 'string' && src.trim()).map((src) => src.trim()))
const items = Array.isArray(templateRequest?.items) ? templateRequest.items : []
const filtered = requestedIds.size ? items.filter((item) => item?.id && requestedIds.has(item.id)) : items
return filtered.map((item) => ({
...item,
label: typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim() ? itemLabels[item.id].trim().slice(0, 60) : item.label,
}))
const filtered =
requestedIds.size || requestedSrcs.size
? items.filter((item) => (item?.id && requestedIds.has(item.id)) || (typeof item?.src === 'string' && requestedSrcs.has(item.src.trim())))
: items
return filtered
.filter((item) => typeof item?.src === 'string' && item.src.trim())
.map((item) => {
const draftLabel =
typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim()
? itemLabels[item.id].trim().slice(0, 60)
: typeof item?.label === 'string' && item.label.trim()
? item.label.trim().slice(0, 60)
: buildItemLabelFromSrc(item.src)
return {
...item,
src: item.src.trim(),
label: draftLabel,
}
})
}
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
@@ -658,11 +711,36 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
res.json({ request })
})
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.type !== 'create') return res.status(409).json({ error: 'create_request_required' })
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
return res.status(409).json({ error: 'request_already_handled' })
}
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const request = await updateTemplateRequestTargetGame({
id: templateRequest.id,
targetGameId: game.id,
})
res.json({ request })
})
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]),
itemLabels: z.record(z.string().min(1).max(60)).optional().default({}),
itemSrcs: z.array(z.string().min(1)).optional().default([]),
itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -676,10 +754,32 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const items = await promoteSnapshotItemsToGame({
items: pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels),
gameId: game.id,
})
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
if (!promotableItems.length) {
return res.status(400).json({ error: 'no_items_selected' })
}
let items = []
try {
items = await promoteSnapshotItemsToGame({
items: promotableItems,
gameId: game.id,
})
} catch (error) {
console.error('[admin] template request promote-items failed', {
requestId: templateRequest.id,
gameId: game.id,
itemCount: promotableItems.length,
message: error?.message || 'unknown_error',
code: error?.code || '',
stack: error?.stack || '',
})
return res.status(500).json({
error: 'promote_items_failed',
detail: error?.message || 'unknown_error',
code: error?.code || '',
})
}
const request =
templateRequest.status === 'reviewing'