공유 덱 카드 복원 기준을 imgSrc로 전환

동일 번호 카드가 많은 시리즈에서도 선택한 카드가 정확히 복원되도록 저장·검증·복원 흐름을 imgSrc 중심으로 정리하고, 이미지 누락 시 공통 뒷면 폴백을 적용한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 17:59:46 +09:00
parent 2c4f622408
commit c193975c86
10 changed files with 100 additions and 24 deletions

View File

@@ -1 +1 @@
v4.0.16
v4.0.17

View File

@@ -1,6 +1,6 @@
{
"name": "uadeck-api",
"version": "4.0.16",
"version": "4.0.17",
"type": "module",
"main": "src/index.js",
"scripts": {

View File

@@ -1,13 +1,19 @@
import { pathToFileURL } from 'url';
import path from 'path';
/** @type {Map<string, Map<string, object>>} */
/**
* @typedef {object} CardIndex
* @property {Map<string, object>} byNumber
* @property {Map<string, object>} byImgSrc
*/
/** @type {Map<string, CardIndex>} */
const cache = new Map();
/**
* 시리즈 카드 맵 로드
* 시리즈 카드 인덱스 로드
* @param {string} seriesKey
* @returns {Promise<Map<string, object>>}
* @returns {Promise<CardIndex>}
*/
export async function getCardMap(seriesKey) {
if (cache.has(seriesKey)) {
@@ -25,27 +31,34 @@ export async function getCardMap(seriesKey) {
}
const byNumber = new Map();
const byImgSrc = new Map();
for (const card of mod.cardList) {
byNumber.set(card.Number, card);
if (!byNumber.has(card.Number)) {
byNumber.set(card.Number, card);
}
if (card.imgSrc && !byImgSrc.has(card.imgSrc)) {
byImgSrc.set(card.imgSrc, card);
}
}
cache.set(seriesKey, byNumber);
return byNumber;
const index = { byNumber, byImgSrc };
cache.set(seriesKey, index);
return index;
}
/**
* 저장된 덱 카드를 풀 카드 객체로 합성
* @param {string} seriesKey
* @param {Array<{number: string, count: number, key: boolean}>} cards
* @param {Array<{number?: string, imgSrc?: string, count: number, key: boolean}>} cards
* @returns {Promise<{cards: object[], totalCards: number}>}
*/
export async function resolveDeckCards(seriesKey, cards) {
const map = await getCardMap(seriesKey);
const index = await getCardMap(seriesKey);
const resolved = [];
let totalCards = 0;
for (const item of cards) {
const base = map.get(item.number);
const base = (item.imgSrc && index.byImgSrc.get(item.imgSrc)) || index.byNumber.get(item.number);
if (!base) continue;
totalCards += item.count;

View File

@@ -18,23 +18,24 @@ export function generateShareCode(length = 8) {
/**
* 덱 본문 정규화 (해시용)
* @param {Array<{number: string, count: number, key: boolean}>} cards
* @param {Array<{imgSrc: string, number?: string, count: number, key: boolean}>} cards
* @returns {string}
*/
export function normalizeCards(cards) {
const sorted = [...cards]
.map((c) => ({
imgSrc: String(c.imgSrc).trim(),
number: String(c.number).trim(),
count: Number(c.count),
key: Boolean(c.key),
}))
.sort((a, b) => a.number.localeCompare(b.number));
.sort((a, b) => a.imgSrc.localeCompare(b.imgSrc));
return JSON.stringify(sorted);
}
/**
* 프리셋 덱용 내용 해시 (8자)
* @param {Array<{number: string, count: number, key: boolean}>} cards
* @param {Array<{imgSrc: string, number?: string, count: number, key: boolean}>} cards
* @returns {string}
*/
export function contentHash(cards) {
@@ -45,7 +46,7 @@ export function contentHash(cards) {
/**
* 덱 카드 배열 검증
* @param {unknown} cards
* @returns {Array<{number: string, count: number, key: boolean}>}
* @returns {Array<{imgSrc: string, number: string, count: number, key: boolean}>}
*/
export function validateCards(cards) {
if (!Array.isArray(cards) || cards.length === 0) {
@@ -54,6 +55,9 @@ export function validateCards(cards) {
let total = 0;
const normalized = cards.map((c) => {
if (!c.imgSrc || typeof c.imgSrc !== 'string' || !c.imgSrc.trim()) {
throw new Error('카드 형식이 올바르지 않습니다. (imgSrc 필요)');
}
if (!c.number || c.count == null) {
throw new Error('카드 형식이 올바르지 않습니다.');
}
@@ -63,6 +67,7 @@ export function validateCards(cards) {
}
total += count;
return {
imgSrc: String(c.imgSrc).trim(),
number: String(c.number).trim(),
count,
key: Boolean(c.key),

View File

@@ -247,6 +247,21 @@
let selectedColors = new Set(['All']);
let currentShareCode = null;
let currentDeckName = '';
const CARD_IMAGE_FALLBACK_SRC = '/images/uapr/comingsoon.png';
/**
* 카드 데이터의 실제 렌더링 이미지 URL 생성
* @param {string} currentSeriesKey
* @param {string} cardImgSrc
* @returns {string}
*/
function resolveCardImageSrc(currentSeriesKey, cardImgSrc) {
const fileName = (cardImgSrc || '').split('/').pop();
if (!currentSeriesKey || !fileName) {
return CARD_IMAGE_FALLBACK_SRC;
}
return `/images/${currentSeriesKey}/${fileName}`;
}
const getEl = () => ({
searchQuery: document.getElementById('searchQuery'),
@@ -408,13 +423,12 @@
cardContainer.className = 'cardContainer group flex flex-col p-2 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-blue-300 transition-colors';
cardContainer.setAttribute('data-imgsrc', card.imgSrc);
const fileName = card.imgSrc.split('/').pop();
const imgSrc = `images/${seriesKey}/${fileName}`;
const imgSrc = resolveCardImageSrc(seriesKey, card.imgSrc);
cardContainer.innerHTML = `
<div class="card_image cursor-pointer relative w-full mb-2 rounded overflow-hidden bg-slate-50" style="aspect-ratio: 2/3;">
<img src="${imgSrc}" class="absolute inset-0 w-full h-full object-contain transition-transform"
onerror="console.error('이미지 로드 실패:', '${card.Number}', '${imgSrc}'); this.src='/images/uapr/comingsoon.png'; this.onerror=null;">
onerror="this.src='${CARD_IMAGE_FALLBACK_SRC}'; this.onerror=null;">
</div>
<div class="flex justify-between items-center mb-1.5 px-0.5">
@@ -737,7 +751,11 @@
function showOverlay(card, index, list) {
const overlay = document.getElementById('imageOverlay');
const img = overlay.querySelector('img');
img.src = `images/${seriesKey}/${card.imgSrc.split('/').pop()}`;
img.src = resolveCardImageSrc(seriesKey, card.imgSrc);
img.onerror = () => {
img.onerror = null;
img.src = CARD_IMAGE_FALLBACK_SRC;
};
overlay.style.display = 'flex';
overlay.onclick = (e) => {
if (e.target === overlay) overlay.style.display = 'none';

View File

@@ -1,5 +1,15 @@
# 변경사항
## v4.0.17
### 공유 덱 카드 복원 정확도 개선
공유 링크로 덱을 불러올 때 카드 복원 기준을 `imgSrc`로 통일해, 같은 카드 번호를 가진 일반·패러렐 카드가 섞여 있어도 선택한 카드가 정확히 다시 표시됩니다.
### 누락 이미지 표시 방식 개선
카드 목록과 덱 프리뷰에서 이미지 경로가 잘못되었거나 파일이 없는 경우 깨진 이미지 대신 카드 뒷면 이미지를 표시하도록 개선했습니다.
## v4.0.16
### 회원 탈퇴

View File

@@ -1,5 +1,10 @@
# 의사결정 이력
## 2026-06-25 v4.0.17 — 공유 덱 카드는 imgSrc를 기준으로 복원
💡 동일 `Number`의 일반·패러렐·프로모가 공존해 번호만으로는 원본 카드를 안정적으로 식별할 수 없기 때문.
🔧 덱 저장 payload에 `imgSrc`를 필수 포함하고 서버 복원은 `imgSrc` 우선, 레거시 데이터만 `number`로 보조 복원.
## 2026-06-25 v4.0.16 — 탈퇴 계정의 덱은 익명 콘텐츠로 보존
💡 공개 덱을 참고하거나 즐겨찾기한 다른 사용자의 이용 흐름을 탈퇴로 끊지 않기 위함.

View File

@@ -3,7 +3,7 @@
## 프로젝트 개요
- 서비스명: `UNIARE - Union Arena Deck Builder`
- 현재 버전: `v4.0.16`
- 현재 버전: `v4.0.17`
- 프론트: 정적 HTML, CSS, JavaScript
- 백엔드: Express, SQLite(`better-sqlite3`)
- 운영: Docker Compose 기반 Nginx + API
@@ -69,6 +69,7 @@
- 폴백 이미지: `/images/uapr/comingsoon.png`
- 브랜드 자산: `images/assets/`
- `images/**`는 Git LFS로 관리한다.
- 카드 목록·덱 결과 화면에서 카드 이미지 로드 실패 시 폴백 이미지를 표시한다.
- 아바타와 덱 미리보기는 API 데이터 디렉터리에 저장하며 Git에 포함하지 않는다.
- 덱 저장 미리보기는 1200px 기준 PNG로 정규화한다.
- 덱 삭제 시 해당 공유 코드의 미리보기 PNG도 함께 삭제한다.
@@ -122,8 +123,9 @@
- 덱 저장과 공개 링크 생성은 로그인한 회원만 사용할 수 있다.
- 비회원은 덱 구성과 PNG 이미지 다운로드만 사용할 수 있다.
- 기존 비회원 공유 덱은 호환을 위해 원래 만료일까지 조회할 수 있다.
- DB의 `cards_json`에는 `{number, count, key}`만 저장한다.
- DB의 `cards_json`에는 `{imgSrc, number, count, key}`를 저장한다.
- API 응답 시 `datas/<series>.js`에서 이미지·색상·희귀도·트리거 정보를 합성한다.
- 같은 `Number`에 여러 판본이 있을 경우 API는 저장된 `imgSrc`를 기준으로 카드 메타를 복원한다.
- 공개 덱 목록은 `share_type='user'`, `is_public=1`, 미만료 조건을 사용한다.
- `preset` 타입과 관리자 프리셋 API는 레거시 호환용으로 코드에 남아 있으며 현재 공개 덱 화면에서는 사용하지 않는다.

View File

@@ -1,5 +1,13 @@
# 업데이트 이력
## 2026-06-25 / v4.0.17 — 누락 이미지 폴백·공유 덱 매핑 보정
### 수행 작업
- 덱 저장 카드 payload를 `imgSrc` 필수로 확장하고 API 검증에서 `imgSrc` 누락 요청을 차단
- 덱 복원은 `imgSrc` 기준으로 정확 매핑하고, 기존 구데이터 호환을 위해서만 `number`를 보조 fallback으로 유지
- `cardList.html`, `result.html` 카드 렌더링에서 이미지 로드 실패 시 공통 폴백(`/images/uapr/comingsoon.png`)을 사용
- 카드 목록 확대 미리보기(오버레이)도 동일 폴백을 적용해 이미지 누락 시 깨짐 대신 뒷면 노출
## 2026-06-25 / v4.0.16 — 회원 탈퇴·관리자 덱 일괄 삭제
### 수행 작업

View File

@@ -526,6 +526,7 @@
let currentStyle = 'type1';
let selectedCards = [];
let seriesKey = '';
const CARD_IMAGE_FALLBACK_SRC = '/images/uapr/comingsoon.png';
// 전역 함수로 등록 (onclick 이벤트 대응)
window.changeStyle = changeStyle;
@@ -537,6 +538,20 @@
let translations = {};
/**
* 카드 이미지 URL 생성 (누락 시 공통 뒷면 사용)
* @param {string} currentSeriesKey
* @param {string} cardImgSrc
* @returns {string}
*/
function resolveCardImageSrc(currentSeriesKey, cardImgSrc) {
const fileName = (cardImgSrc || '').split('/').pop();
if (!currentSeriesKey || !fileName) {
return CARD_IMAGE_FALLBACK_SRC;
}
return `/images/${currentSeriesKey}/${fileName}`;
}
document.addEventListener('DOMContentLoaded', async () => {
const urlParams = new URLSearchParams(window.location.search);
const currentLang = urlParams.get('lang') || localStorage.getItem('lang') || 'ko';
@@ -653,15 +668,14 @@
const div = document.createElement('div');
div.className = `${widthClass} card-wrapper`;
const fileName = card.imgSrc.split('/').pop();
const imgSrc = `/images/${seriesKey}/${fileName}`;
const imgSrc = resolveCardImageSrc(seriesKey, card.imgSrc);
const keyLabelText = translations.keyCardLabel || "KEY CARD";
div.innerHTML = `
<div class="card-badge">${card.count}</div>
<div class="card-image-box ${isKey ? 'key-card-highlight' : ''}">
<img src="${imgSrc}" class="w-full h-full object-contain" crossorigin="anonymous">
<img src="${imgSrc}" class="w-full h-full object-contain" crossorigin="anonymous" onerror="this.src='${CARD_IMAGE_FALLBACK_SRC}'; this.onerror=null;">
${isKey ? `<div class="absolute bottom-0 left-0 right-0 key-label text-[10px] text-center py-1 font-bold">${keyLabelText}</div>` : ''}
</div>
<div class="mt-2 text-[10px] font-mono text-slate-400">${card.Number}</div>
@@ -726,6 +740,7 @@
return selectedCards
.filter((c) => c.count > 0)
.map((c) => ({
imgSrc: c.imgSrc,
number: c.Number,
count: c.count,
key: Boolean(c.key),