diff --git a/backend/src/db.js b/backend/src/db.js index ec80daa..79911bc 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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, } diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 95df30a..62d7d24 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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' diff --git a/docs/history.md b/docs/history.md index fa5a00d..58d2f9b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-02 v1.3.59 +- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다. +- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다. +- 신규 템플릿 요청 카드는 생성 여부가 관리자의 머릿속 상태가 아니라 UI 메타로 드러나야 하므로, `연결된 게임 있음/없음`과 `이미 반영 n개`를 카드와 작업 패널 양쪽에서 함께 보여주는 편이 맞다고 정리했다. + ## 2026-04-02 v1.3.55 - 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다. - 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index ecc8225..e2cb145 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,8 @@ # 할 일 및 이슈 ## 중기 개선 +- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. +- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다. - 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다. - 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다. - 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다. diff --git a/docs/update.md b/docs/update.md index 8ca9718..d2805e0 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-04-02 v1.3.59 +- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함. +- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함. +- 신규 템플릿 요청으로 새 게임을 한 번 만들면 해당 요청과 새 게임을 연결해 저장하고, 이후 같은 요청에서 다시 `확인하기`를 눌렀을 때는 새 게임을 또 만들지 않고 기존에 연결된 게임으로 바로 복귀하도록 흐름을 정리함. +- 따라서 요청 카드와 게임 관리 작업 패널에서는 `연결된 게임`, `이미 반영 n개` 같은 상태를 함께 보여, 처리 완료 전에도 현재 진행 정도와 재작업 위험을 더 쉽게 구분할 수 있게 함. + ## 2026-04-02 v1.3.55 - 관리자 요청 카드 오른쪽 상단의 `신규 템플릿 / 보유 템플릿` 배지는 서로 다른 색상으로 분리해, 카드 타입을 텍스트보다 더 빠르게 구분할 수 있게 조정함. - 게임 관리의 기본 아이템 추가 미리보기에서도 `요청 아이템 / 직접 추가 파일` 배지를 서로 다른 색상으로 구분해, 요청 반영분과 직접 업로드분이 한눈에 섞이지 않도록 정리함. diff --git a/frontend/src/components/admin/AdminGamesSection.vue b/frontend/src/components/admin/AdminGamesSection.vue index 47f88d2..677df43 100644 --- a/frontend/src/components/admin/AdminGamesSection.vue +++ b/frontend/src/components/admin/AdminGamesSection.vue @@ -5,6 +5,7 @@ const props = defineProps({ activeTemplateRequest: { type: Object, default: null }, templateRequestSourceUrl: { type: Function, required: true }, stagedRequestDraftCount: { type: Number, required: true }, + appliedRequestItemCount: { type: Number, required: true }, openGameCreateModal: { type: Function, required: true }, isGameLoading: { type: Boolean, required: true }, hasSelectedGame: { type: Boolean, required: true }, @@ -29,6 +30,10 @@ const props = defineProps({ removeGameItem: { type: Function, required: true }, selectedGameId: { type: String, default: '' }, }) + +function setGameItemListElement(el) { + props.gameItemListRef(el) +}