From 9f6cb33bbd45834978f12c05a1b8fc397d76a10e Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Apr 2026 21:23:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.4.29=20?= =?UTF-8?q?=EB=A0=88=EA=B1=B0=EC=8B=9C=20game=20=ED=9D=94=EC=A0=81=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=ED=98=B8=ED=99=98=EC=B8=B5=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 34 +++++++++---------- backend/src/routes/admin.js | 2 +- backend/src/routes/tierlists.js | 6 ++-- docs/history.md | 4 +++ docs/todo.md | 3 ++ docs/update.md | 5 +++ .../src/composables/useAdminCustomItems.js | 4 +-- frontend/src/views/AdminView.vue | 4 +-- frontend/src/views/TierEditorView.vue | 2 +- 9 files changed, 38 insertions(+), 26 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 28cc82a..b75e8d9 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -972,8 +972,8 @@ async function listReferencedUploadUsage() { ]) for (const row of userRows) addUsage(row.avatar_src, 'avatar') - for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail') - for (const row of gameItemRows) addUsage(row.src, 'game-item') + for (const row of gameRows) addUsage(row.thumbnail_src, 'topic-thumbnail') + for (const row of gameItemRows) addUsage(row.src, 'topic-item') for (const row of customItemRows) addUsage(row.src, 'custom-item') for (const row of tierListRows) { @@ -1517,7 +1517,7 @@ async function getCustomItemUsageMeta() { ` ) const usageMap = new Map() - const linkedGamesMap = new Map() + const linkedTemplatesMap = new Map() rows.forEach((row) => { const groups = parseJson(row.groups_json, []) @@ -1541,8 +1541,8 @@ async function getCustomItemUsageMeta() { if (!row.topic_id) return seenItemIds.forEach((itemId) => { - if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map()) - linkedGamesMap.get(itemId).set(row.topic_id, { + if (!linkedTemplatesMap.has(itemId)) linkedTemplatesMap.set(itemId, new Map()) + linkedTemplatesMap.get(itemId).set(row.topic_id, { id: row.topic_id, name: row.topic_name || row.topic_id, }) @@ -1551,7 +1551,7 @@ async function getCustomItemUsageMeta() { return { usageMap, - linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])), + linkedTemplatesMap: new Map(Array.from(linkedTemplatesMap.entries()).map(([itemId, templateMap]) => [itemId, Array.from(templateMap.values())])), } } @@ -1620,7 +1620,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod }) const customItems = customRows.map((row) => { - const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()) + const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()) return { id: row.id, ownerId: row.owner_id, @@ -1630,7 +1630,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod ownerName: row.nickname || row.email, ownerEmail: row.email, usageCount: usageMeta.usageMap.get(row.id) || 0, - linkedGames, + linkedTemplates, sourceType: 'user', sourceLabel: '사용자 업로드', canDelete: true, @@ -1651,7 +1651,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod ownerName: '관리자 보관 자산', ownerEmail: '', usageCount: 0, - linkedGames: [], + linkedTemplates: [], sourceType: 'template', sourceLabel: '관리자 템플릿', canDelete: true, @@ -1669,7 +1669,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod ownerName: row.topic_name || row.topic_id, ownerEmail: '', usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size, - linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), + linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), sourceType: 'template', sourceLabel: '관리자 템플릿', canDelete: true, @@ -1688,7 +1688,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod const allItems = baseItems .map((item) => { const siblings = groupedBySrc.get(item.src) || [item] - const linkedGames = new Map() + const linkedTemplates = new Map() let userReferenceCount = 0 let templateReferenceCount = 0 let assetReferenceCount = 0 @@ -1697,8 +1697,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod if (entry.sourceType === 'user') userReferenceCount += 1 else if (entry.isAssetLibraryItem) assetReferenceCount += 1 else templateReferenceCount += 1 - ;(entry.linkedGames || []).forEach((game) => { - if (game?.id) linkedGames.set(game.id, game) + ;(entry.linkedTemplates || []).forEach((template) => { + if (template?.id) linkedTemplates.set(template.id, template) }) }) @@ -1708,7 +1708,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod sharedUserReferenceCount: userReferenceCount, sharedTemplateReferenceCount: templateReferenceCount, sharedAssetReferenceCount: assetReferenceCount, - sharedLinkedGameCount: linkedGames.size, + sharedLinkedTemplateCount: linkedTemplates.size, sharedEntries: siblings .slice() .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) @@ -1722,7 +1722,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod sourceTopicId: entry.sourceTopicId || '', sourceTopicName: entry.sourceTopicName || '', usageCount: entry.usageCount || 0, - linkedGames: entry.linkedGames || [], + linkedTemplates: entry.linkedTemplates || [], isAssetLibraryItem: !!entry.isAssetLibraryItem, })), } @@ -1736,7 +1736,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod case 'asset': return !!item.isAssetLibraryItem case 'unused-user': - return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0 + return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0 case 'unused-admin': return !!item.isAssetLibraryItem default: @@ -2011,7 +2011,7 @@ function uniqueTierListItems(poolItems) { id: item.id, src: item.src || '', label: item.label || 'item', - origin: item.origin || 'game', + origin: item.origin || 'template', }) }) return Array.from(map.values()) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 692248f..0f7a82a 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -631,7 +631,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { } if (!target.canDelete) return res.status(409).json({ error: 'item_locked' }) - if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' }) + if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' }) if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' }) const items = await findCustomItemsByIds([target.id]) diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 9e59280..53fe83c 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -24,7 +24,7 @@ const FREEFORM_TOPIC_ID = 'freeform' const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기' function normalizePoolItem(item) { - if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item + if (!item || !['game', 'template'].includes(item.origin) || typeof item.src !== 'string') return item if (item.src.startsWith('/uploads/')) return item try { @@ -83,7 +83,7 @@ const templateRequestSchema = z.object({ id: z.string().min(1), src: z.string().min(1), label: z.string().min(1).max(60), - origin: z.enum(['game', 'custom']).default('game'), + origin: z.enum(['template', 'game', 'custom']).default('template'), }) ), }) @@ -112,7 +112,7 @@ const tierListUpsertSchema = z.object({ id: z.string().min(1), src: z.string().min(1), label: z.string().min(1).max(60), - origin: z.enum(['game', 'custom']).default('game'), + origin: z.enum(['template', 'game', 'custom']).default('template'), }) ), }).superRefine((value, ctx) => { diff --git a/docs/history.md b/docs/history.md index 466e6f9..a9d7dc0 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.4.29 +- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다. +- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다. + ## 2026-04-02 v1.4.28 - 이 시점 이후 코드 검색에 남는 `game`는 대부분 레거시 데이터 마이그레이션, 옛 주소 redirect, 저장 데이터의 `origin` 호환처럼 의도된 층이므로, 무리하게 전부 0으로 만들기보다 기능을 깨뜨리지 않는 선에서 의미 있는 이름층만 더 줄이는 편이 맞다고 판단했다. - 관리자 화면 내부 상태명(`selectedTemplate.game`, `isGameLoading`, `gameVisibilitySaving`)은 실제 기능 의미와 어긋나므로, QA 전에 한 번 더 `template` 기준으로 옮겨두는 편이 이후 유지보수에 더 유리하다고 정리했다. diff --git a/docs/todo.md b/docs/todo.md index 3535ab9..8754cc8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.29`에서 새 티어표 데이터 기본 origin을 `template`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다. +- 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다. +- 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다. - `v1.4.28`에서 관리자 템플릿 상세 상태와 기본 아이템 정렬 상태 이름을 `template` 기준으로 더 정리했으므로, 관리자 템플릿 선택/공개 전환/기본 아이템 정렬 저장이 그대로 정상인지 한 번 더 확인한다. - 새 템플릿 썸네일/기본 아이템 업로드는 이제 `topics` 디렉터리로 저장되므로, 실제 업로드 후 최적화 작업 분류와 관리자 최근 작업 표시가 자연스럽게 보이는지 확인한다. - 현재 코드 검색에 남는 `game`는 레거시 redirect, DB 마이그레이션, `origin: 'game'` 호환이 중심이므로, 이 층까지 실제로 없앨지 여부는 `v1.4` QA 후 안정성 기준으로 다시 판단한다. diff --git a/docs/update.md b/docs/update.md index fd2e74b..2f6f03f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.4.29 +- 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다. +- 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다. +- 현재 `game` 검색에 남는 것은 레거시 주소 redirect, DB 마이그레이션용 legacy 테이블/컬럼명, 과거 저장 데이터 호환용 `origin: 'game'`처럼 의도적으로 남겨둔 층만 남도록 정리했다. + ## 2026-04-02 v1.4.28 - 관리자 템플릿 상세 상태(`selectedTemplate.game`)와 관련 응답 키를 `template` 기준으로 정리해, 내부 코드 검색에서 남던 `game` 흔적을 더 줄였다. - 관리자 기본 아이템 정렬/로딩 상태 이름도 `templateItem*`, `isTemplateLoading`, `templateVisibilitySaving` 기준으로 바꾸고, 새 템플릿 자산 업로드는 `topics` 디렉터리로 저장되게 맞췄다. diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js index 9dc9819..178af18 100644 --- a/frontend/src/composables/useAdminCustomItems.js +++ b/frontend/src/composables/useAdminCustomItems.js @@ -85,7 +85,7 @@ export function useAdminCustomItems({ function openCustomItemDeleteModal(item) { if (!item) return - if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) { + if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) { error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' return } @@ -109,7 +109,7 @@ export function useAdminCustomItems({ async function removeCustomItem(item = modalTargetCustomItem.value) { resetMessages() if (!item) return - if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) { + if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) { error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' return } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 924de2e..d303f7d 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -620,7 +620,7 @@ const imageDiagnosticsCards = computed(() => { ] }) const visibleLinkedTemplates = computed(() => - (modalTargetCustomItem.value?.linkedGames || []).filter((template) => template?.id && template.id !== 'freeform') + (modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform') ) const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean))) @@ -1369,7 +1369,7 @@ function buildModalItemFromTierListItem(item, tierList) { sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'), sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템', ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList), - linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [], + linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [], usageCount: matchedItem?.usageCount || 0, canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false, isPromoting: false, diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index b4e9485..d9fb62f 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -904,7 +904,7 @@ onMounted(() => { id: img.id, src: img.src, label: img.label, - origin: 'game', + origin: 'template', })) const map = {} base.forEach((it) => (map[it.id] = it))