From 1d936966ae0af22c9c531d004582e57e48e5079c Mon Sep 17 00:00:00 2001 From: clkim Date: Fri, 19 Dec 2025 17:39:46 +0900 Subject: [PATCH] =?UTF-8?q?fix.=20=EC=84=9C=EB=B2=84=20=EB=AF=B8=EB=93=A4?= =?UTF-8?q?=EC=9B=A8=EC=96=B4,=20=EB=AF=B8=EB=93=A4=EC=9B=A8=EC=96=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TYPE_CHANGE_VERIFICATION.md | 79 ---- app/app.vue | 2 +- app/pages/[d1]/[d2]/[d3].vue | 2 +- app/pages/[d1]/[d2]/index.vue | 2 +- app/pages/[d1]/index.vue | 2 +- app/pages/index.vue | 2 +- app/pages/inspection/index.vue | 59 +-- layers/assets/css/base/_font.css | 4 - layers/components/atoms/Button/index.vue | 14 +- layers/components/atoms/Img.vue | 19 +- layers/components/layouts/EventNavigation.vue | 2 +- layers/components/layouts/Main.vue | 14 +- layers/composables/useGetGameDomain.ts | 35 -- layers/composables/usePathResolver.ts | 41 -- layers/middleware/init.route.global.ts | 151 +++---- layers/middleware/inspection.ts | 79 +--- layers/middleware/pageData.global.ts | 242 ++++------ layers/plugins/router-state.client.ts | 10 + layers/server/middleware/gameData.ts | 415 ++++++------------ layers/stores/useGameDataStore.ts | 10 +- layers/stores/useModalStore.ts | 9 + layers/utils/localeUtil.ts | 25 +- layers/utils/urlUtil.ts | 88 ++++ nuxt.config.ts | 68 ++- tailwind.config.ts | 16 + 25 files changed, 592 insertions(+), 798 deletions(-) delete mode 100644 TYPE_CHANGE_VERIFICATION.md delete mode 100644 layers/composables/useGetGameDomain.ts delete mode 100644 layers/composables/usePathResolver.ts create mode 100644 layers/plugins/router-state.client.ts create mode 100644 layers/utils/urlUtil.ts diff --git a/TYPE_CHANGE_VERIFICATION.md b/TYPE_CHANGE_VERIFICATION.md deleted file mode 100644 index 3f44ec2..0000000 --- a/TYPE_CHANGE_VERIFICATION.md +++ /dev/null @@ -1,79 +0,0 @@ -# 타입 변경 검증 리포트 - -## 변경 사항 - -`NodeJS.Timeout` 타입을 `ReturnType`으로 변경하여 더 범용적으로 사용 가능하도록 개선했습니다. - -## 변경된 파일 - -1. `layers/stores/useModalStore.ts` - `toastTimeoutId` 타입 변경 -2. `layers/stores/useLoadingStore.ts` - `apiLoadingTimeoutId` 타입 변경 -3. `layers/components/atoms/Video.vue` - `pauseTimeoutId` 타입 변경 -4. `layers/templates/GrGallery01/index.vue` - `stopVideoTimeoutId` 타입 변경 -5. `layers/composables/useGameStart.ts` - `launcherTimeoutId` 타입 변경 - -## 타입 호환성 검증 - -### ✅ 타입 체크 통과 -- `pnpm typecheck` 명령어 실행 결과: **성공** (에러 없음) -- 모든 파일에서 타입 에러 없음 - -### ✅ clearTimeout 호환성 -- `clearTimeout`은 `ReturnType` 타입을 정상적으로 받을 수 있음 -- 브라우저 환경: `clearTimeout(number)` ✅ -- Node.js 환경: `clearTimeout(NodeJS.Timeout)` ✅ - -### ✅ 사용 패턴 검증 -모든 파일에서 다음 패턴이 정상적으로 작동함: -```typescript -// 타입 선언 -const timeoutId = ref | null>(null) - -// setTimeout 사용 -timeoutId.value = setTimeout(() => { - // ... -}, delay) - -// clearTimeout 사용 -if (timeoutId.value) { - clearTimeout(timeoutId.value) // ✅ 정상 작동 - timeoutId.value = null -} -``` - -## 타입 비교 - -### 이전: `NodeJS.Timeout` -- **장점**: Node.js 환경에서 명확한 타입 -- **단점**: 브라우저 환경에서 타입 불일치 가능성 -- **문제**: 브라우저에서는 `setTimeout`이 `number`를 반환하므로 타입 에러 발생 가능 - -### 변경 후: `ReturnType` -- **장점**: - - 환경에 따라 자동으로 적절한 타입 선택 - - 브라우저: `number` - - Node.js: `NodeJS.Timeout` - - 범용적이고 유연함 -- **단점**: 없음 -- **결과**: 모든 환경에서 정상 작동 - -## 검증 결과 - -### ✅ 타입 안정성 -- TypeScript 컴파일러가 모든 타입을 정상적으로 추론 -- 타입 에러 없음 - -### ✅ 런타임 호환성 -- `clearTimeout`이 모든 환경에서 정상 작동 -- 브라우저와 Node.js 모두 지원 - -### ✅ 코드 품질 -- 더 범용적이고 유지보수하기 쉬운 타입 -- 환경 의존성 제거 - -## 결론 - -**✅ 변경 완료 및 검증 통과** - -`ReturnType` 타입으로 변경해도 문제가 없으며, 오히려 더 범용적이고 안전한 타입입니다. 모든 타입 체크를 통과했고, `clearTimeout`과의 호환성도 확인되었습니다. - diff --git a/app/app.vue b/app/app.vue index e218623..d0327e3 100644 --- a/app/app.vue +++ b/app/app.vue @@ -23,7 +23,7 @@ const metaData = ref(null) // SSR에서 게임 데이터 가져오기 const getGameDataFromServer = (): GameDataValue | null => { return import.meta.server - ? (nuxtApp.ssrContext?.event.context.gameData ?? null) + ? nuxtApp.ssrContext?.event?.context?.gameData : null } diff --git a/app/pages/[d1]/[d2]/[d3].vue b/app/pages/[d1]/[d2]/[d3].vue index b19118f..6dc68f1 100644 --- a/app/pages/[d1]/[d2]/[d3].vue +++ b/app/pages/[d1]/[d2]/[d3].vue @@ -2,8 +2,8 @@ import { usePageDataStore } from '#layers/stores/usePageDataStore' import type { PageDataValue } from '#layers/types/api/pageData' +const currentLayout = ref<'default' | 'promotion' | null>('default') const currentPageData = ref(null) -const currentLayout = ref<'default' | 'promotion' | null>(null) onMounted(() => { const pageDataStore = usePageDataStore() diff --git a/app/pages/[d1]/[d2]/index.vue b/app/pages/[d1]/[d2]/index.vue index b19118f..6dc68f1 100644 --- a/app/pages/[d1]/[d2]/index.vue +++ b/app/pages/[d1]/[d2]/index.vue @@ -2,8 +2,8 @@ import { usePageDataStore } from '#layers/stores/usePageDataStore' import type { PageDataValue } from '#layers/types/api/pageData' +const currentLayout = ref<'default' | 'promotion' | null>('default') const currentPageData = ref(null) -const currentLayout = ref<'default' | 'promotion' | null>(null) onMounted(() => { const pageDataStore = usePageDataStore() diff --git a/app/pages/[d1]/index.vue b/app/pages/[d1]/index.vue index b19118f..6dc68f1 100644 --- a/app/pages/[d1]/index.vue +++ b/app/pages/[d1]/index.vue @@ -2,8 +2,8 @@ import { usePageDataStore } from '#layers/stores/usePageDataStore' import type { PageDataValue } from '#layers/types/api/pageData' +const currentLayout = ref<'default' | 'promotion' | null>('default') const currentPageData = ref(null) -const currentLayout = ref<'default' | 'promotion' | null>(null) onMounted(() => { const pageDataStore = usePageDataStore() diff --git a/app/pages/index.vue b/app/pages/index.vue index b19118f..6dc68f1 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -2,8 +2,8 @@ import { usePageDataStore } from '#layers/stores/usePageDataStore' import type { PageDataValue } from '#layers/types/api/pageData' +const currentLayout = ref<'default' | 'promotion' | null>('default') const currentPageData = ref(null) -const currentLayout = ref<'default' | 'promotion' | null>(null) onMounted(() => { const pageDataStore = usePageDataStore() diff --git a/app/pages/inspection/index.vue b/app/pages/inspection/index.vue index 21b1e8c..714d1fc 100644 --- a/app/pages/inspection/index.vue +++ b/app/pages/inspection/index.vue @@ -79,13 +79,17 @@
{{ tm('Txt_Game_Start') }} - + - - -
- + @@ -139,7 +140,11 @@ :class="`platform-type-${gameData?.platform_type}`" >

- {{ t('Inspection_Txt_Download', { gameName: gameData?.game_name }) }} + {{ + t('Inspection_Txt_Download', { + gameName: gameData?.game_name, + }) + }}

import { globalDateFormat } from '@seed-next/date' - const config = useRuntimeConfig() const stoveApiUrl = config.public.stoveApiUrl as string @@ -185,7 +189,8 @@ const { t, tm, locale }: any = useI18n({ const device = useDevice() const loadingStore = useLoadingStore() -const { webInspectionData, getInspectionDataExternal } = useGetInspectionDataExternal() +const { webInspectionData, getInspectionDataExternal } = + useGetInspectionDataExternal() const gameDataStore = useGameDataStore() const { gameData } = storeToRefs(gameDataStore) await getInspectionDataExternal({ @@ -244,10 +249,12 @@ const enabledMarkets = computed(() => { platform, url: info.url as string, })) - + // platform_type이 1이면 app_store, google_play 제외하고 pc 추가 if (platformType === 1) { - const filteredMarkets = markets.filter(m => m.platform !== 'app_store' && m.platform !== 'google_play') + const filteredMarkets = markets.filter( + m => m.platform !== 'app_store' && m.platform !== 'google_play' + ) const hasPc = filteredMarkets.some(m => m.platform === 'pc') if (!hasPc) { filteredMarkets.unshift({ @@ -257,12 +264,12 @@ const enabledMarkets = computed(() => { } return filteredMarkets } - + // platform_type이 2이면 pc 제외 if (platformType === 2) { return markets.filter(m => m.platform !== 'pc') } - + // platform_type이 3이면 pc 항목 추가 if (platformType === 3) { const hasPc = markets.some(m => m.platform === 'pc') @@ -273,7 +280,7 @@ const enabledMarkets = computed(() => { }) } } - + return markets }) @@ -310,15 +317,18 @@ const getButtonText = (platform: string) => { google_play: 'platform_google_play', app_store: 'platform_app_store', } - + const key = platformKeyMap[platform] return key ? tm(key) : '' } const handleGameStart = () => { - - const os = device.isAndroid ? 'google_play' : device.isApple ? 'app_store' : 'google_play' - + const os = device.isAndroid + ? 'google_play' + : device.isApple + ? 'app_store' + : 'google_play' + //const os = device.isAndroid ? 'google_play' : device.isApple ? 'app_store' : 'pc' // platform_type이 2이면서 device.isDesktop이면 window.open(gameData.value.market_json[os].url, '_blank') @@ -328,11 +338,8 @@ const handleGameStart = () => { onMounted(() => { loadingStore.stopFullLoading() - - console.log("🚀 ~ 3333 onMounted ~ enabledMarkets:", enabledMarkets.value) }) - definePageMeta({ middleware: ['inspection'], layout: 'only-stove', @@ -480,11 +487,11 @@ definePageMeta({ @apply w-full flex-none hidden md:flex md:flex-1; } - .inspection-download-card .btn-platform-app_store { @apply flex-1 px-2 md:px-4; } -.inspection-download-card.platform-type-3 .btn-platform-app_store, .inspection-download-card.platform-type-3 .btn-platform-google_play { +.inspection-download-card.platform-type-3 .btn-platform-app_store, +.inspection-download-card.platform-type-3 .btn-platform-google_play { @apply flex-1 px-2 md:px-4 md:flex-none; } :deep(.inspection-download-card .btn-platform-app_store .text) { @@ -499,7 +506,9 @@ definePageMeta({ :deep(.inspection-download-card .btn-platform-google_play .text) { @apply block md:hidden; } -:deep(.inspection-download-card.platform-type-2 .btn-platform-google_play .text) { +:deep( + .inspection-download-card.platform-type-2 .btn-platform-google_play .text +) { @apply block md:block; } :deep(.inspection-download-card .btn-base.single .icon-platform) { diff --git a/layers/assets/css/base/_font.css b/layers/assets/css/base/_font.css index e761210..a924faf 100644 --- a/layers/assets/css/base/_font.css +++ b/layers/assets/css/base/_font.css @@ -1,7 +1,3 @@ -@import url('https://static-cdn.onstove.com/resources/stds/stds-font-kr/stds-font-kr.css'); -@import url('https://static-cdn.onstove.com/resources/stds/stds-font-global/stds-font-global.css'); -/* @import url(https://static-cdn.onstove.com/0.0.4/font-icon/StoveFont-Icon.css); */ - :lang(ko) { font-family: stds-font-kr, diff --git a/layers/components/atoms/Button/index.vue b/layers/components/atoms/Button/index.vue index 8380b84..29edaf6 100644 --- a/layers/components/atoms/Button/index.vue +++ b/layers/components/atoms/Button/index.vue @@ -24,8 +24,9 @@ const componentTag = computed((): string => { switch (props.type) { case 'external': case 'link': - case 'download': return 'a' + case 'download': + return props.href ? 'a' : 'button' case 'internal': return 'AtomsLocaleLink' default: @@ -47,11 +48,14 @@ const componentProps = computed(() => { } if (props.type === 'download') { - return { - href: props.href, - target: '_self', - download: props.href?.split('/').pop() ?? 'download', + if (props.href) { + return { + href: props.href, + target: '_self', + download: props.href?.split('/').pop() ?? 'download', + } } + return {} } return {} diff --git a/layers/components/atoms/Img.vue b/layers/components/atoms/Img.vue index 77d3bb0..2311491 100644 --- a/layers/components/atoms/Img.vue +++ b/layers/components/atoms/Img.vue @@ -41,8 +41,23 @@ const imagePaths = computed(() => { - + - + diff --git a/layers/components/layouts/EventNavigation.vue b/layers/components/layouts/EventNavigation.vue index dd3d44f..d6120a8 100644 --- a/layers/components/layouts/EventNavigation.vue +++ b/layers/components/layouts/EventNavigation.vue @@ -6,9 +6,9 @@ import type { } from '#layers/types/api/eventNavigation' const { locale } = useI18n() -const gameDomain = useGetGameDomain() const { sendLog } = useAnalytics() +const gameDomain = getGameDomain() const analytics = { action_type: 'click', click_sarea: 'EventNavigation', diff --git a/layers/components/layouts/Main.vue b/layers/components/layouts/Main.vue index f09070b..5c5c67b 100644 --- a/layers/components/layouts/Main.vue +++ b/layers/components/layouts/Main.vue @@ -14,11 +14,12 @@ const props = defineProps() const mainContentRef = ref() -const { locale } = useI18n() +const { tm, locale } = useI18n() const { height: viewportH } = useWindowSize() const { bottom: mainBottom } = useElementBounding(mainContentRef) const { getTemplateComponent } = useTemplateRegistry() const loadingStore = useLoadingStore() +const modalStore = useModalStore() const { isPAssApiLoading, hasApiCallStarted } = storeToRefs(loadingStore) @@ -80,6 +81,17 @@ onMounted(() => { if (!hasApiCallStarted.value) { loadingStore.stopFullLoading() } + + // 페이지 접근 권한 설정(로그인 유무) + if (props.pageData?.is_login_required === 1 && !csrGetAccessToken()) { + modalStore.handleOpenConfirm({ + contentText: tm('Alert_StoveLogin'), + confirmButtonText: tm('Text_StoveLogin'), + confirmButtonEvent: () => { + csrGoStoveLogin() + }, + }) + } }) diff --git a/layers/composables/useGetGameDomain.ts b/layers/composables/useGetGameDomain.ts deleted file mode 100644 index 353352f..0000000 --- a/layers/composables/useGetGameDomain.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getHeader, getRequestHost } from 'h3' -import { useRequestEvent } from 'nuxt/app' - -/** - * 게임 도메인을 가져오는 컴포저블 함수 - * 서버와 클라이언트 환경에서 모두 동작 - * @returns 게임 도메인 문자열 - */ -export const useGetGameDomain = (): string => { - try { - if (import.meta.client) { - const host = window.location.host || '' - return host.split(':')[0] - } - - const event = useRequestEvent() - if (!event) { - return '' - } - - // 미들웨어에서 설정한 gameDomain가 있다면 우선 사용 - if (event.context.gameDomain) { - return event.context.gameDomain - } - - const host = - (getHeader(event, 'host') || getRequestHost(event)).toString() || '' - const cleanHost = host.split(':')[0] - - return cleanHost || '' - } catch (error) { - console.error('useGetGameDomain error:', error) - return '' - } -} diff --git a/layers/composables/usePathResolver.ts b/layers/composables/usePathResolver.ts deleted file mode 100644 index c5122a4..0000000 --- a/layers/composables/usePathResolver.ts +++ /dev/null @@ -1,41 +0,0 @@ -export const usePathResolver = () => { - const getPathAfterLanguage = (url?: string): string => { - // URL이 제공되지 않으면 현재 URL 사용 - const targetUrl = - url || (import.meta.client ? window.location.pathname : '') - - // URL에서 언어 코드 패턴을 찾아서 그 뒤의 경로를 추출 - // 예: /ko/about/story -> /about/story - // 예: /en/test/page -> /test/page - // 예: /zh-tw/about/story -> /about/story - // 예: /zh-cn/test/page -> /test/page - // 예: /ko -> "" (빈 문자열) - const languagePattern = /^\/[a-z]{2}(-[a-z]{2})?\/(.+)$/ - const match = targetUrl.match(languagePattern) - - if (match && match[2]) { - return `/${match[2]}` - } - - // 언어 코드만 있고 뒤에 아무것도 없는 경우 (예: /ko, /en, /zh-tw, /zh-cn) - const languageOnlyPattern = /^\/[a-z]{2}(-[a-z]{2})?$/ - if (languageOnlyPattern.test(targetUrl)) { - return '' - } - - // 언어 코드가 없는 경우 원본 경로 그대로 반환 (이미 /로 시작) - return targetUrl - } - - const getCurrentPath = (): string => { - if (import.meta.client) { - return getPathAfterLanguage() - } - return '' - } - - return { - getPathAfterLanguage, - getCurrentPath, - } -} diff --git a/layers/middleware/init.route.global.ts b/layers/middleware/init.route.global.ts index f0d4ec8..f710257 100644 --- a/layers/middleware/init.route.global.ts +++ b/layers/middleware/init.route.global.ts @@ -1,92 +1,83 @@ -import type { GameDataValue } from '#layers/types/api/gameData' +export default defineNuxtRouteMiddleware(to => { + // error 페이지는 실행X ----- + if (to.path.includes('/error')) return -export default defineNuxtRouteMiddleware(async (to, _from) => { - const nuxtApp = useNuxtApp() - const getGameDataFromServer = (): GameDataValue | null => { - return import.meta.server - ? (nuxtApp.ssrContext?.event.context.gameData ?? null) - : null - } + // inspection 페이지는 실행X ----- + if (to.path.includes('inspection')) return - const serverGameData = getGameDataFromServer() const gameDataStore = useGameDataStore() + const { gameData } = storeToRefs(gameDataStore) - const { setGameData } = gameDataStore + // app.vue에서 설정한 스토어 값이 없으면 대기 + if (!gameData.value) return - if (serverGameData) { - setGameData(serverGameData) - } - try { - // 서버 사이드에서는 스킵 - if (!import.meta.client) { - return - } - - // 현재 경로에서 언어 코드 추출 - const gameData = gameDataStore.gameData as GameDataValue - const langCodes = gameData?.lang_codes - const currentLangCode = csrGetFinalLocale(to.path, langCodes) - const { getPathAfterLanguage } = usePathResolver() - const pageUrl = getPathAfterLanguage(to.path) - - //현재 url에서 게임 도메인만 추출 - // const currentDomain = window.location.hostname - // const runtimeConfig = useRuntimeConfig() - - // 쿼리스트링에서 f 파라미터 값 추출 (CSR용) - // const fValue = (to.query.f as string) || '' - - // 미리보기 API 호출 처리 - // let finalGameDomain = currentDomain - // if (fValue === 'preview') { - // finalGameDomain = 'samplegame.onstove.com' - // } - - // const req: GameDataRequest = { - // gameDomain: `${finalGameDomain}`, - // langCode: `${currentLangCode}`, - // game_alias: '', - // lang_code: `${currentLangCode}`, - // baseApiUrl: `${runtimeConfig.public.stoveApiUrl}`, - // gameId: '', - // } - // const { getGameDataExternal } = useGetGameDataExternal() - // await getGameDataExternal(req) - - // error 페이지는 API 호출하지 않음 - if ( - pageUrl === '/error' || - to.path.includes('/error') - ) { - console.log('🚀 ~ init.route.global error 페이지는 API 호출하지 않음') - showError( - createError({ - statusCode: 500, - statusMessage: 'Internal Server Error', - fatal: false, // 즉시 에러 페이지로 - data: { reason: 'post-not-found' }, - }) + const gamePath = getPathAfterLanguage(to.path) + const langCode = import.meta.client + ? csrGetFinalLocale(to.path, gameData.value.lang_codes) + : ssrGetFinalLocale( + to.path, + useRequestHeaders(['accept-language']), + gameData.value.lang_codes, + gameData.value.default_lang_code ) - return + + const isRootPath = gamePath === '' || gamePath === '/' + + if (isRootPath) { + // gameData.intro.page_url이 있으면 해당 URL로 리다이렉트, 없으면 /home으로 + const introPageUrl = gameData.value?.intro?.page_url + let defaultPath = `/${langCode}/home` + + if (introPageUrl && introPageUrl.trim() !== '') { + // 외부 URL인지 확인 + const isExternalUrl = + introPageUrl.startsWith('http://') || + introPageUrl.startsWith('https://') + + if (isExternalUrl) { + // 외부 URL인 경우 그대로 사용 + defaultPath = introPageUrl + } else { + // 내부 경로인 경우 언어 코드 패턴 확인 + const normalizedIntroUrl = introPageUrl.split('?')[0] // 쿼리스트링 제외 + const languagePattern = /^\/[a-z]{2}(-[a-z]{2})?(\/|$)/ + const hasLanguageCode = languagePattern.test(normalizedIntroUrl) + + if (hasLanguageCode) { + // 이미 언어 코드가 있으면 그대로 사용 + defaultPath = introPageUrl + } else { + // 언어 코드가 없으면 추가 + const pathWithSlash = normalizedIntroUrl.startsWith('/') + ? normalizedIntroUrl + : `/${normalizedIntroUrl}` + defaultPath = `/${langCode}${pathWithSlash}` + + // 쿼리스트링이 있으면 다시 추가 + if (introPageUrl.includes('?')) { + defaultPath += '?' + introPageUrl.split('?')[1] + } + } + } } - // 허용된 언어 코드 목록≈≈ - const allowedLangCodes = langCodes || [] + // 무한 리다이렉트 방지: 현재 경로와 리다이렉트할 URL 비교 + const normalizedFinalUrl = defaultPath.split('?')[0] // 쿼리스트링 제외 + const currentPath = to.path + const isExternalUrl = + defaultPath.startsWith('http://') || defaultPath.startsWith('https://') + const isSamePath = !isExternalUrl && currentPath === normalizedFinalUrl - // 현재 언어가 허용된 언어 목록에 없으면 에러 페이지로 이동 - if (currentLangCode && !allowedLangCodes.includes(currentLangCode)) { - return navigateTo(`/${currentLangCode}/error`, { external: true }) + if (!isSamePath) { + // 다른 경로에서 접근한 경우에만 리다이렉트 + const queryString = to.fullPath.includes('?') + ? '?' + to.fullPath.split('?')[1] + : '' + const redirectUrl = + queryString && !defaultPath.includes('?') + ? `${defaultPath}${queryString}` + : defaultPath + return navigateTo(redirectUrl, { external: isExternalUrl }) } - } catch (error) { - console.error(error) - showError( - createError({ - statusCode: error?.statusCode || error?.status || 500, - statusMessage: - error?.statusMessage || error?.message || 'Internal Server Error', - fatal: false, // 즉시 에러 페이지로 - data: { reason: 'post-not-found' }, - }) - ) } }) diff --git a/layers/middleware/inspection.ts b/layers/middleware/inspection.ts index 16dc837..e2710a9 100644 --- a/layers/middleware/inspection.ts +++ b/layers/middleware/inspection.ts @@ -1,39 +1,34 @@ export default defineNuxtRouteMiddleware(async to => { + const runtimeConfig = useRuntimeConfig() + + // server에서는 실행X ----- + if (import.meta.server) return + + // error 페이지는 실행X ----- + if (to.path.includes('/error')) return + try { - //error 발생시에는 미들웨어 실행하지 않음 - //error 객체 조회 - if (!import.meta.client) { - return - } - - const { getPathAfterLanguage } = usePathResolver() - const pageUrl = getPathAfterLanguage(to.path) - - // error 페이지는 API 호출하지 않음 - if (pageUrl === '/error' || to.path.includes('/error')) { - console.log("🚀 ~ inspection error 페이지는 API 호출하지 않음") - return - } - const gameDataStore = useGameDataStore() - const runtimeConfig = useRuntimeConfig() - const { gameData } = storeToRefs(gameDataStore) - // const baseDomain = `${runtimeConfig.public.baseDomain}` - const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl - const stoveGameId = gameData.value.game_id - // const stoveMaintenanceApiUrl = `${runtimeConfig.public.stoveMaintenanceApiUrl}` - // const localeCookie = useCookie('LOCALE', { - // domain: baseDomain - // }) + // app.vue에서 설정한 스토어 값이 없으면 대기 + if (!gameData.value) return + + const stoveApiBaseUrl = `${runtimeConfig.public.stoveApiUrl}` + // const baseDomain = `${runtimeConfig.public.baseDomain}` + // const stoveMaintenanceApiUrl = `${runtimeConfig.public.stoveMaintenanceApiUrl}` + const stoveGameId = gameData.value.game_id + + /* const localeCookie = useCookie('LOCALE', { + domain: baseDomain + }) */ const finalLocale = csrGetFinalLocale(to.path, gameData.value.lang_codes) // localeCookie.value = finalLocale.toUpperCase() // 웹 점검 ----- - const { isWebInspection, getInspectionDataExternal } = useGetInspectionDataExternal() - + const { isWebInspection, getInspectionDataExternal } = + useGetInspectionDataExternal() await getInspectionDataExternal({ baseApiUrl: stoveApiBaseUrl, gameId: stoveGameId, @@ -48,24 +43,8 @@ export default defineNuxtRouteMiddleware(async to => { // lang: `${finalLocale}`.toLowerCase() // }) - // 테스트 수정 - // isWebInspection.value === - // if ( - // !isWebInspection.value && - // !to.path.includes('inspection') && - // !to.path.includes('api') - // ) { - // console.log("🚀 ~ 점검 중인 경우") - // // 점검 중인 경우 - // return navigateTo(`/${finalLocale}/inspection`, { external: true }) - // } else if ( isWebInspection.value && !to.path.includes('inspection') ) { - // // 점검이 종료된 후 점검 페이지 접근시 메인으로 리다이렉트 - // console.log("🚀 ~ 점검이 종료된 후 점검 페이지 접근시 메인으로 리다이렉트") - // return navigateTo(`/${finalLocale}`, { external: true }) - // } - if ( - isWebInspection.value === true && + isWebInspection.value && !to.path.includes('inspection') && !to.path.includes('api') ) { @@ -78,20 +57,6 @@ export default defineNuxtRouteMiddleware(async to => { // 점검이 종료된 후 점검 페이지 접근시 메인으로 리다이렉트 return navigateTo(`/${finalLocale}`, { external: true }) } - - //이동한 페이지는 에러 페이지로 리다이렉트 - - const error = useError() - if(error.value?.statusCode){ - return showError(createError({ - statusCode: error.value?.statusCode, - statusMessage: error.value?.message, - fatal: true, - data: { path: to.path } - })) - } - - } catch (e) { console.error('[Exception] /middleware/inspection: ', e) } diff --git a/layers/middleware/pageData.global.ts b/layers/middleware/pageData.global.ts index c0fd4c1..f84356e 100644 --- a/layers/middleware/pageData.global.ts +++ b/layers/middleware/pageData.global.ts @@ -1,130 +1,58 @@ -import { commonFetch } from '#layers/utils/apiUtil' import { usePageDataStore } from '#layers/stores/usePageDataStore' import { useLoadingStore } from '#layers/stores/useLoadingStore' -import { useGetGameDomain } from '#layers/composables/useGetGameDomain' -import { usePathResolver } from '#layers/composables/usePathResolver' +import { commonFetch } from '#layers/utils/apiUtil' +import { getGameDomain, getPathAfterLanguage } from '#layers/utils/urlUtil' import type { PageDataResponse } from '#layers/types/api/pageData' export default defineNuxtRouteMiddleware(async (to, _from) => { - // client에서만 동작되도록 처리 - if (!import.meta.client) return - const runtimeConfig = useRuntimeConfig() - const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl - const gameDomain = useGetGameDomain() + // server에서는 실행X ----- + if (import.meta.server) return + + // error 페이지는 실행X ----- + if (to.path.includes('/error')) return + + // inspection 페이지는 실행X ----- + if (to.path.includes('inspection')) return + const gameDataStore = useGameDataStore() - const { gameData } = storeToRefs(gameDataStore) const pageDataStore = usePageDataStore() const loadingStore = useLoadingStore() - const { getPathAfterLanguage } = usePathResolver() - const langCode = csrGetFinalLocale(to.path, gameData.value?.lang_codes) + const { gameData } = storeToRefs(gameDataStore) + + const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl + const accessToken = csrGetAccessToken() + const gameDomain = getGameDomain() + const gamePath = getPathAfterLanguage(to.path) + const langCode = + csrGetFinalLocale(to.path, gameData.value?.lang_codes) || 'ko' + + let pageDataResponse: PageDataResponse | null = null try { - if (to.path.includes('inspection')) { - console.log('🚀 ~ 점검페이지 접근 pageData.global') - return - } - - const pageUrl = getPathAfterLanguage(to.path) - - // 루트 경로(언어 코드만 있는 경로)로 접근할 때만 intro.page_url로 리다이렉트 - const isRootPath = - !pageUrl || - pageUrl === '' || - pageUrl === '/' || - pageUrl === `/${langCode}/` - console.log('🚀 ~ isRootPath:', isRootPath) - - if (isRootPath) { - // gameData.intro.page_url이 있으면 해당 URL로 리다이렉트 - const introPageUrl = gameData.value?.intro?.page_url - if (introPageUrl && introPageUrl.trim() !== '') { - // 외부 URL인지 확인 - const isExternalUrl = - introPageUrl.startsWith('http://') || - introPageUrl.startsWith('https://') - - // 내부 경로인 경우 언어 코드 추가 - let finalIntroUrl = introPageUrl - if (!isExternalUrl) { - const normalizedIntroUrl = introPageUrl.split('?')[0] // 쿼리스트링 제외 - - // 언어 코드 패턴 확인 (예: /ko, /en, /zh-tw 등) - const languagePattern = /^\/[a-z]{2}(-[a-z]{2})?(\/|$)/ - const hasLanguageCode = languagePattern.test(normalizedIntroUrl) - - // 언어 코드가 없으면 추가 - if (!hasLanguageCode) { - // 경로가 /로 시작하지 않으면 / 추가 - const pathWithSlash = normalizedIntroUrl.startsWith('/') - ? normalizedIntroUrl - : `/${normalizedIntroUrl}` - finalIntroUrl = `/${langCode}${pathWithSlash}` - - // 쿼리스트링이 있으면 다시 추가 - if (introPageUrl.includes('?')) { - finalIntroUrl += '?' + introPageUrl.split('?')[1] - } - } - } - - // 무한 리다이렉트 방지: 현재 경로와 리다이렉트할 URL 비교 - const normalizedFinalUrl = finalIntroUrl.split('?')[0] // 쿼리스트링 제외 - const currentPath = to.path - const isSamePath = !isExternalUrl && currentPath === normalizedFinalUrl - - if (!isSamePath) { - // 다른 경로에서 접근한 경우에만 리다이렉트 - const queryString = to.fullPath.includes('?') - ? '?' + to.fullPath.split('?')[1] - : '' - const redirectUrl = - queryString && !finalIntroUrl.includes('?') - ? `${finalIntroUrl}${queryString}` - : finalIntroUrl - console.log('🚀 ~ pageData.global redirectUrl:', redirectUrl) - return navigateTo(redirectUrl, { external: isExternalUrl }) - } - } - } - - // pageUrl이 빈값이거나 null이면 /home로 리다이렉트 - if ( - !pageUrl || - pageUrl === '' || - pageUrl === '/' || - pageUrl === `/${langCode}/` - ) { - console.log('🚀 ~ pageData.global /home 리다이렉트') - return navigateTo(`/${langCode}/home`, { external: false }) - } - - // error 페이지는 API 호출하지 않음 - if (pageUrl === '/error' || to.path.includes('/error')) return - // 페이지 이동 시 로딩 상태 시작 loadingStore.startFullLoading() - const accessToken = csrGetAccessToken() const headers = { Authorization: `Bearer ${accessToken}`, } - // 미리보기 쿼리스트링에서 파라미터 값 추출 - // preview?page_seq=1&page_ver=1&lang_code=ko - const queryString = to.fullPath.includes('?') - ? to.fullPath.split('?')[1] - : '' - const urlParams = new URLSearchParams(queryString) - const pageSeq = urlParams.get('page_seq') || '' - const pageVer = urlParams.get('page_ver') || '' - const queryLangCode = urlParams.get('lang_code') || langCode - let queryParams: Record let apiUrl: string - if (pageUrl === '/preview') { + + if (gamePath === '/preview') { + // 미리보기 쿼리스트링에서 파라미터 값 추출 + // preview?page_seq=1&page_ver=1&lang_code=ko + const queryString = to.fullPath.includes('?') + ? to.fullPath.split('?')[1] + : '' + const urlParams = new URLSearchParams(queryString) + const pageSeq = urlParams.get('page_seq') || '' + const pageVer = urlParams.get('page_ver') || '' + const queryLangCode = urlParams.get('lang_code') || langCode + apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/page/preview` queryParams = { lang_code: queryLangCode, @@ -137,79 +65,20 @@ export default defineNuxtRouteMiddleware(async (to, _from) => { queryParams = { game_domain: gameDomain, lang_code: langCode, - page_url: pageUrl, + page_url: gamePath, _t: Date.now().toString(), // 캐시 무효화를 위한 타임스탬프 } } - const response = (await commonFetch('GET', apiUrl, { + pageDataResponse = (await commonFetch('GET', apiUrl, { headers, query: queryParams, })) as PageDataResponse | null - console.log('🚀 ~ pageData.global response:', response.value) - // 페이지 접근 권한 설정(로그인 유무) - if (response?.value?.is_login_required === 1 && !accessToken) { - // 로그인 레이어 팝업 띄워주기 - const nuxtApp = useNuxtApp() - const modalStore = useModalStore() - const $i18n = nuxtApp.$i18n as any - const { tm } = $i18n - modalStore.handleOpenConfirm({ - contentText: tm('Alert_StoveLogin'), - confirmButtonText: tm('Text_StoveLogin'), - confirmButtonEvent: () => { - csrGoStoveLogin() - }, - }) - } - - // 404 에러 코드 체크 - const isNotFoundError = - (response?.code === 91002 && response?.message === 'Invalid LangCode') || - response?.code === 91003 || - response?.code === 90004 - - if (isNotFoundError) { - //클릭한 주소는 주소표시줄에 표시하도록 수정 - if (import.meta.client) { - window.history.replaceState({}, '', to.path) - } - // 뒤로가기 이동 시 이전 페이지로 이동되도록 수정 - showError( - createError({ - statusCode: 404, - statusMessage: response?.message, - fatal: false, // 즉시 에러 페이지로 - data: { reason: response?.message }, - }) - ) - } - - if (response?.code === 90002) { - //클릭한 주소는 주소표시줄에 표시하도록 수정 - if (import.meta.client) { - window.history.replaceState({}, '', to.path) - } - // 뒤로가기 이동 시 이전 페이지로 이동되도록 수정 - showError( - createError({ - statusCode: 500, - statusMessage: response?.message, - fatal: false, // 즉시 에러 페이지로 - data: { reason: response?.message }, - }) - ) - } - - if (response?.code === 0 && 'value' in response) { - pageDataStore.setPageData(response.value) - } else { - pageDataStore.clearPageData() - } + console.log('🚀 ~ pageData.global response:', pageDataResponse?.value) } catch (error) { - console.error(error) pageDataStore.clearPageData() + console.error(error) showError( createError({ @@ -220,4 +89,41 @@ export default defineNuxtRouteMiddleware(async (to, _from) => { }) ) } + + if (pageDataResponse?.code === 0 && 'value' in pageDataResponse) { + pageDataStore.setPageData(pageDataResponse.value) + return + } else { + // 90001 (API Respond 4xx status): API 응답 4xx 에러 + // 91001 (Invalid GameCode): 게임 코드 없음 + // 91003 (Invalid PageUrl): 페이지 주소 없음 + if ( + pageDataResponse?.code === 90001 || + pageDataResponse?.code === 91001 || + pageDataResponse?.code === 91003 + ) { + showError( + createError({ + statusCode: 404, + statusMessage: pageDataResponse?.message, + fatal: false, // 즉시 에러 페이지로 + data: { reason: pageDataResponse?.message }, + }) + ) + return + } + // 91002 (Invalid LangCode): 미지원 언어로 접근 + if (pageDataResponse?.code === 91002) { + navigateTo(`/${langCode}/home`) + return + } + + // [TODO] + // 90004 (Not found user): 사용자 없음 + // if (pageDataResponse?.code === 90043) { + // return navigateTo(`/${langCode}/home`) + // } + } + + pageDataStore.clearPageData() }) diff --git a/layers/plugins/router-state.client.ts b/layers/plugins/router-state.client.ts new file mode 100644 index 0000000..765a2ca --- /dev/null +++ b/layers/plugins/router-state.client.ts @@ -0,0 +1,10 @@ +export default defineNuxtPlugin(() => { + const router = useRouter() + const modalStore = useModalStore() + + router.beforeEach((to, from) => { + if (to.path !== from.path) { + modalStore.handleResetModalAll() + } + }) +}) diff --git a/layers/server/middleware/gameData.ts b/layers/server/middleware/gameData.ts index 95612e1..ce62723 100644 --- a/layers/server/middleware/gameData.ts +++ b/layers/server/middleware/gameData.ts @@ -1,20 +1,14 @@ import { LRUCache } from 'lru-cache' -import { - getHeader, - getRequestHost, - defineEventHandler, - createError, - setCookie, - type H3Event, -} from 'h3' -import { ssrGetFinalLocale } from '../../utils/localeUtil' -import type { GameDataResponse } from '../../types/api/gameData' +import { defineEventHandler, createError, setCookie, type H3Event } from 'h3' +import { getGameDomain, getPathLocale } from '#layers/utils/urlUtil' +import { ssrGetFinalLocale } from '#layers/utils/localeUtil' +import { isStaticFile } from '#layers/utils/commonUtil' +import { getTrueClientIp } from '#layers/utils/apiUtil' +import type { GameDataResponse } from '#layers/types/api/gameData' import type { ResGetInspectionData, WebInspectionData, -} from '../../types/InspectionType' -import { isStaticFile } from '#layers/utils/commonUtil' -import { getTrueClientIp } from '#layers/utils/apiUtil' +} from '#layers/types/InspectionType' /** * 캐시 제어 헤더를 설정하는 공통 함수 @@ -66,7 +60,7 @@ function setCacheHeaders( event.node.res.setHeader('Cache-Control', cacheControl) } -const cache = new LRUCache({ +const cache = new LRUCache({ max: 100, // 캐시에 저장할 최대 항목 수 ttl: 1000 * 30, // 30초 동안 캐시 유지 }) @@ -103,17 +97,8 @@ function fnLocaleMiddleware(event: H3Event, finalLocale: string) { } const path = event?.node.req.url || '' - let arrPath = [] - let queryString = '' - - if (path.includes('?')) { - // 쿼리스트링 포함 시 순수 경로만 추출 - arrPath = path.split('?')[0].split('/') - queryString = path.split('?')[1] - } else { - arrPath = path.split('/') - queryString = '' - } + const [pathPart, queryString = ''] = path.split('?') + const arrPath = pathPart.split('/') // 최종 언어 세팅된 경로 생성 const pathLocale = arrPath.length > 1 ? arrPath[1] : '' @@ -128,7 +113,7 @@ function fnLocaleMiddleware(event: H3Event, finalLocale: string) { newLocalePath = arrPath.join('/') } - if (queryString !== '') { + if (queryString) { newLocalePath += `?${queryString}` } @@ -138,13 +123,26 @@ function fnLocaleMiddleware(event: H3Event, finalLocale: string) { } } -export default defineEventHandler(async event => { - // HMR 요청 필터링 (개발 모드에서 중복 실행 방지) - if (event.path.startsWith('/_nuxt/') || event.path.startsWith('/__nuxt')) { - return - } +/** + * 특정 경로를 건너뛰어야 하는지 확인 + */ +function shouldSkipPath(path: string): boolean { + return ( + path.startsWith('/_nuxt/') || + path.startsWith('/__nuxt') || + path.startsWith('/api/') || + path.includes('/assets/') || + path.includes('favicon') || + path === '/robots.txt' || + path === '/sitemap.xml' + ) +} - if (event.path.includes('/error')) { +export default defineEventHandler(async event => { + const runtimeConfig = useRuntimeConfig() + + // HMR 요청 필터링 (개발 모드에서 중복 실행 방지) + if (shouldSkipPath(event.path)) { return } @@ -153,166 +151,97 @@ export default defineEventHandler(async event => { return } - const runtimeConfig = useRuntimeConfig() - const iBaseApiUrl = runtimeConfig.public.stoveApiUrlServer + const stoveApiServerBaseUrl = runtimeConfig.public.stoveApiUrlServer const baseDomain = runtimeConfig.public.baseDomain - const apiUrl = `${iBaseApiUrl}/pub-comm/v1.0/template/game` - let initLangCodes: string[] | null = null + let gameDataResponse: GameDataResponse | null = null + let gameDataLangCodes: string[] | null = null + let gameDataDefaultLocale: string | null = null let finalLocale: string - let cleanHost: string | undefined - let initDefaultLocale: string | null = null - - const host = - (getHeader(event, 'host') || getRequestHost(event)).toString() || '' - const isGameDomainExtractable = host.includes(baseDomain) - - if (isGameDomainExtractable) { - cleanHost = host.split(':')[0] - event.context.gameDomain = cleanHost - } try { + const gameApiUrl = `${stoveApiServerBaseUrl}/pub-comm/v1.0/template/game` + const gameDomain = getGameDomain(event) const queryParams: Record = { - game_domain: cleanHost || '', - lang_code: '', + game_domain: gameDomain || '', + lang_code: getPathLocale(event?.node.req.url), } - const initResponse = await $fetch(apiUrl, { + const response = await $fetch(gameApiUrl, { query: queryParams, }) - initLangCodes = initResponse?.value?.lang_codes || null - initDefaultLocale = initResponse?.value?.default_lang_code || null + gameDataResponse = response + gameDataLangCodes = response?.value?.lang_codes || null + gameDataDefaultLocale = response?.value?.default_lang_code || null + event.context.gameDomain = gameDomain } catch (error) { - console.error('init gameData load error:', error) - - return + // eslint-disable-next-line no-console + console.error('gameData load error:', error) } - const fullPath = event.path + if (gameDataResponse?.code === 0 && 'value' in gameDataResponse) { + // ### 정상 응답 처리 ------------------------------------------------------------- + const gameDataValue = gameDataResponse.value + event.context.gameData = gameDataValue + event.context.googleAnalyticsId = gameDataValue?.ga_code + console.log('🚀 ~ gameData response:', event.context.gameData) - // 1-1. 정적 파일 패스 - if (isStaticFile(event.path)) { - return - } + // ------------------------------------------------------------------------------- + // [Inspection Middleware] + // ------------------------------------------------------------------------------- + const fullPath = event.path - // 1-2. /inspection 패스 - if (fullPath.includes('/inspection')) { - // 리턴 되기 전 언어 쿠키 세팅 - finalLocale = ssrGetFinalLocale( - event?.node.req.url, - event.node.req.headers, - initLangCodes, - initDefaultLocale - ) - setFinalLocaleCookie(event, finalLocale, baseDomain) - - // 점검 페이지에서도 gameData를 가져와서 context에 설정 - try { - const queryParams: Record = { - game_domain: cleanHost || '', - lang_code: finalLocale, - } - const response = await $fetch(apiUrl, { - query: queryParams, - }) - - if (response?.code === 0 && 'value' in response) { - event.context.gameData = response.value - event.context.googleAnalyticsId = response.value?.ga_code - } - } catch (error) { - console.error('inspection path gameData load error:', error) - } - - return - } + // 1-1. 정적 파일 패스 + if (isStaticFile(event.path)) return - // 정적 자산, API, 파비콘 등은 제외하고 페이지 요청만 처리 - if ( - fullPath.startsWith('/api/') || - fullPath.startsWith('/_nuxt/') || - fullPath.startsWith('/favicon') || - fullPath.includes('/assets/') || - fullPath.includes('.') || - fullPath.startsWith('/_') - ) { - return - } - - // 캐시 키 생성 - const cacheKey = 'inspection' - - try { - // 이미 응답이 종료되었는지 확인 - if (event.node.res.headersSent || event.node.res.writableEnded) { + // 1-2. /inspection 패스 + if (fullPath.includes('/inspection')) { + // 리턴 되기 전 언어 쿠키 세팅 + const finalLocale = ssrGetFinalLocale( + event?.node.req.url, + event.node.req.headers, + gameDataLangCodes, + gameDataDefaultLocale + ) + setFinalLocaleCookie(event, finalLocale, baseDomain) return } - // 2. 언어 코드 추출 - finalLocale = ssrGetFinalLocale( - event?.node.req.url, - event.node.req.headers, - initLangCodes, - initDefaultLocale - ) - const queryParams: Record = { - game_domain: cleanHost || '', - lang_code: finalLocale, - } + // 1-3. 특정 경로 패스 (API, 리소스) + if (shouldSkipPath(fullPath)) return - const response = await $fetch(apiUrl, { - query: queryParams, - }) + // 캐시 키 생성 (게임 ID 포함하여 충돌 방지) + const gameId = gameDataValue?.game_id || 'default' + const cacheKey = `inspection:${gameId}` - console.log('🚀 ~ gameData response:', response) - - // 언어패스 쿠키 굽기 - 장기방안에서는 굽지않음 - if (initLangCodes?.includes(finalLocale)) { + try { + // 2. 언어 코드 추출 + finalLocale = ssrGetFinalLocale( + event?.node.req.url, + event.node.req.headers, + gameDataLangCodes, + gameDataDefaultLocale + ) setFinalLocaleCookie(event, finalLocale, baseDomain) - } - if (response?.code === 91001) { - // 91001 에러 발생 시 바로 /error 페이지로 리다이렉트 - if (!event.node.res.headersSent && !event.node.res.writableEnded) { - const errorPath = `/${finalLocale || 'ko'}/error` - event.node.res.statusCode = 302 - event.node.res.setHeader('Location', errorPath) - event.node.res.end() - return - } - } + // 초기화 + let inspectionData - if (response?.code === 0 && 'value' in response) { - event.context.gameData = response.value - event.context.googleAnalyticsId = response.value?.ga_code - - // 점검 데이터 조회 - let inspectionData: WebInspectionData | undefined - if (cache.has(cacheKey)) { - inspectionData = cache.get(cacheKey) as WebInspectionData + // 3. 캐시된 데이터가 없거나 만료되었을 때만 API 호출 + const cachedData = cache.get(cacheKey) + if (cachedData) { + inspectionData = cachedData } else { - // 점검 데이터 조회 - if (response?.value?.game_id) { - try { - const inspectionApiUrl = `${iBaseApiUrl}/pub-comm/v3.0/inspection/${response?.value?.game_id}` - // 직접 $fetch 사용 (composable 사용하지 않음) - const inspectionResponse = await $fetch( - inspectionApiUrl, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - } - ) - inspectionData = inspectionResponse?.value?.inspection - console.log("🚀 ~ inspectionData:", inspectionData) - cache.set(cacheKey, inspectionData) // 캐시에 저장 - } catch (error) { - console.error('inspection data load error:', error) - // 에러 발생 시 inspectionData는 undefined로 유지 - } + const inspectionApiUrl = `${stoveApiServerBaseUrl}/pub-comm/v3.0/inspection/${gameDataValue?.game_id}` + const response = await $fetch(inspectionApiUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + inspectionData = response?.value?.inspection as WebInspectionData + if (inspectionData) { + cache.set(cacheKey, inspectionData) // 캐시에 저장 } } @@ -336,38 +265,36 @@ export default defineEventHandler(async event => { * - 점검 URL 경로가 아닐 경우 no-cache 설정 * - 화이트 리스트 체크 */ + // 점검 url path 가 아닐 경우, no-cache 설정 const inspectionPath = `/${finalLocale}/inspection` - const isInspectionPath = fullPath === inspectionPath - - // 현재 경로가 점검 페이지가 아닐 경우 캐시 헤더 설정 - if (!isInspectionPath) { + if (fullPath !== inspectionPath) { setCacheHeaders(event, 'no-cache') } - // 응답이 이미 종료되었는지 확인 - if (event.node.res.headersSent || event.node.res.writableEnded) { - return - } - // 점검 중일 때 IP 필터링 활성화 여부 확인 if (inspectionData?.ip_filter_use_yn === 'Y') { const clientIP = getTrueClientIp(event.node.req as any) // 허용된 IP 목록 확인 - if (inspectionData?.ip_filter_list?.includes(clientIP)) { - // 화이트 리스트인 경우 Locale Middleware 실행 - fnLocaleMiddleware(event, finalLocale) - } else { + const allowedIPs = inspectionData?.ip_filter_list || [] + if (!clientIP || !allowedIPs.includes(clientIP)) { // 허용되지 않은 IP인 경우 점검 페이지로 이동 event.node.res.statusCode = 302 event.node.res.setHeader('Location', inspectionPath) event.node.res.end() + } else { + // 화이트 리스트인 경우 + // ------------------------------------------------------------------------------- + // [Locale Middleware] + // ------------------------------------------------------------------------------- + fnLocaleMiddleware(event, finalLocale) } } else { - // IP 필터링이 비활성화된 경우 모든 사용자를 점검 페이지로 이동 - event.node.res.statusCode = 302 - event.node.res.setHeader('Location', inspectionPath) - event.node.res.end() + if (!event.node.res.headersSent && !event.node.res.writableEnded) { + event.node.res.statusCode = 302 + event.node.res.setHeader('Location', inspectionPath) + event.node.res.end() + } } } else { /** @@ -379,127 +306,51 @@ export default defineEventHandler(async event => { * - 점검 30분 이후: 기본 캐시 (60초) */ // 홈 경로: 캐시 없음 - const isHomePath = [ - '', - '/', - //, ...Object.values(DEFAULT_LOCALE_COVERAGES).flatMap((locale) => [`/${locale}`, `/${locale}/`]) - console.log("🚀 ~ isHomePath: 여기야??", fullPath) - ].includes(fullPath) + const isHomePath = fullPath === '' || fullPath === '/' if (isHomePath) { setCacheHeaders(event, 'no-cache') - } else { + } else if (tsStartDate > 0 && timeUntilInspectionSeconds > 0) { // 점검 예정 시간에 따른 캐시 설정 - - if (tsStartDate > 0 && timeUntilInspectionSeconds > 0) { - if (timeUntilInspectionSeconds < 300) { - // 점검 5분 전: 짧은 캐시 (10초) - setCacheHeaders(event, 'short', 10) - } else if (timeUntilInspectionSeconds < 1800) { - // 점검 30분 전: 중간 캐시 (15초) - setCacheHeaders(event, 'medium', 15) - } else { - // 점검 30분 이후: 기본 캐시 (60초) - setCacheHeaders(event, 'default') - } + if (timeUntilInspectionSeconds < 300) { + // 점검 5분 전: 짧은 캐시 (10초) + setCacheHeaders(event, 'short', 10) + } else if (timeUntilInspectionSeconds < 1800) { + // 점검 30분 전: 중간 캐시 (15초) + setCacheHeaders(event, 'medium', 15) + } else { + // 점검 30분 이후: 기본 캐시 (60초) + setCacheHeaders(event, 'default') } } - // ------------------------------------------------------------------------------- - // [Root Path Redirect to /home or intro.page_url] - // 언어 코드만 있는 경로(예: /ko, /ko/)를 /home 또는 intro.page_url로 리다이렉트 - // ------------------------------------------------------------------------------- - const normalizedPath = fullPath.endsWith('/') - ? fullPath.slice(0, -1) - : fullPath - const localePath = `/${finalLocale}` - if (normalizedPath === localePath) { - // intro.page_url이 있으면 해당 값 사용, 없으면 /home 사용 - const introPageUrl = response.value?.intro?.page_url - let defaultPath = `/${finalLocale}/home` - - if (introPageUrl && introPageUrl.trim() !== '') { - // 외부 URL인지 확인 - const isExternalUrl = introPageUrl.startsWith('http://') || introPageUrl.startsWith('https://') - - if (isExternalUrl) { - // 외부 URL인 경우 그대로 사용 - defaultPath = introPageUrl - } else { - // 내부 경로인 경우 언어 코드 패턴 확인 - const normalizedIntroUrl = introPageUrl.split('?')[0] // 쿼리스트링 제외 - const languagePattern = /^\/[a-z]{2}(-[a-z]{2})?(\/|$)/ - const hasLanguageCode = languagePattern.test(normalizedIntroUrl) - - if (hasLanguageCode) { - // 이미 언어 코드가 있으면 그대로 사용 - defaultPath = introPageUrl - } else { - // 언어 코드가 없으면 추가 - const pathWithSlash = normalizedIntroUrl.startsWith('/') - ? normalizedIntroUrl - : `/${normalizedIntroUrl}` - defaultPath = `/${finalLocale}${pathWithSlash}` - - // 쿼리스트링이 있으면 다시 추가 - if (introPageUrl.includes('?')) { - defaultPath += '?' + introPageUrl.split('?')[1] - } - } - } - } - - const queryString = event?.node.req.url?.includes('?') - ? '?' + event.node.req.url.split('?')[1] - : '' - - if (!event.node.res.headersSent && !event.node.res.writableEnded) { - // 쿼리스트링 처리 - let redirectUrl = defaultPath - if (queryString) { - // defaultPath에 이미 쿼리스트링이 있는지 확인 - if (defaultPath.includes('?')) { - redirectUrl = `${defaultPath}&${queryString.substring(1)}` - } else { - redirectUrl = `${defaultPath}${queryString}` - } - } - - event.node.res.statusCode = 302 - event.node.res.setHeader('Location', redirectUrl) - event.node.res.end() - return - } - } - // ------------------------------------------------------------------------------- // [Locale Middleware] // ------------------------------------------------------------------------------- fnLocaleMiddleware(event, finalLocale) } - } - } catch (error: any) { - console.error('gameData load error:', error) - // 응답이 이미 종료되었는지 확인 - if (event.node.res.headersSent || event.node.res.writableEnded) { - return + // 정상 접속 허용 + } catch (error) { + console.error('gameData inspection error:', error) } - + } else { + // ### 에러 응답 처리 ------------------------------------------------------------- // 언어 코드 추출 시도 let errorLocale = 'ko' // 기본값 try { errorLocale = ssrGetFinalLocale( event?.node.req.url, event.node.req.headers, - initLangCodes, - initDefaultLocale + gameDataLangCodes, + gameDataDefaultLocale ) } catch (e) { + // eslint-disable-next-line no-console console.error('Locale extraction error:', e) } // 91001 에러인 경우 바로 리다이렉트 - if (error?.statusCode === 91001 || error?.cause?.statusCode === 91001) { + if (gameDataResponse?.code === 91001) { const errorPath = `/${errorLocale}/error` event.node.res.statusCode = 302 event.node.res.setHeader('Location', errorPath) @@ -509,8 +360,8 @@ export default defineEventHandler(async event => { // 다른 에러는 기존대로 throw throw createError({ - statusCode: error?.statusCode || 500, - statusMessage: error?.statusMessage, + statusCode: gameDataResponse?.code || 500, + statusMessage: gameDataResponse?.message, }) } }) diff --git a/layers/stores/useGameDataStore.ts b/layers/stores/useGameDataStore.ts index 8ad26a2..697d15e 100644 --- a/layers/stores/useGameDataStore.ts +++ b/layers/stores/useGameDataStore.ts @@ -2,19 +2,25 @@ import type { GameDataValue } from '#layers/types/api/gameData' export const useGameDataStore = defineStore('gameData', () => { const gameData = ref(null) - const langCode = ref(null) + const langCodes = ref(null) + const defaultLangCode = ref(null) const setGameData = (data: GameDataValue) => { gameData.value = data + langCodes.value = data.lang_codes + defaultLangCode.value = data.default_lang_code } const clearGameData = () => { gameData.value = null + langCodes.value = null + defaultLangCode.value = null } return { - langCode, gameData, + langCodes, + defaultLangCode, setGameData, clearGameData, } diff --git a/layers/stores/useModalStore.ts b/layers/stores/useModalStore.ts index 2c92d1c..7af2b03 100644 --- a/layers/stores/useModalStore.ts +++ b/layers/stores/useModalStore.ts @@ -154,6 +154,14 @@ export const useModalStore = defineStore('modalStore', () => { content.storeTabActiveIndex.value = tabActiveIndex } + const handleResetModalAll = () => { + alert.storeIsOpen.value = false + confirm.storeIsOpen.value = false + youtube.storeIsOpen.value = false + toast.storeIsOpen.value = false + content.storeIsOpen.value = false + } + return { alert, confirm, @@ -166,5 +174,6 @@ export const useModalStore = defineStore('modalStore', () => { handleOpenToast, handleOpenContent, handleControlDimmed, + handleResetModalAll, } }) diff --git a/layers/utils/localeUtil.ts b/layers/utils/localeUtil.ts index 6268894..1474d28 100644 --- a/layers/utils/localeUtil.ts +++ b/layers/utils/localeUtil.ts @@ -1,4 +1,4 @@ -import { DEFAULT_LOCALE_CODE } from '../../i18n.config' +import { DEFAULT_LOCALE_CODE } from '@/i18n.config' // 사용자 선호 언어 조회 export const getPreferredLanguage = (acceptLanguageHeader = '') => { @@ -97,7 +97,12 @@ export const csrGetFinalLocale = (path = '', coveragesLocales: string[]) => { * @param {string} path - 현재 URL 경로 * @param {any} headers - 요청 헤더 */ -export const ssrGetFinalLocale = (path, headers: any, coveragesLocales: string[], defaultLocale: string) => { +export const ssrGetFinalLocale = ( + path, + headers: any, + coveragesLocales: string[], + defaultLocale: string +) => { let finalLocale = defaultLocale // 기본값 설정 try { @@ -109,7 +114,11 @@ export const ssrGetFinalLocale = (path, headers: any, coveragesLocales: string[] } const pathLocalee = `${path.split('/')[1]}`.toLowerCase() // URL path에 포함된 언어 정보가 지원하는 언어인지 체크 - if (pathLocalee && pathLocalee !== '' && coveragesLocales.includes(pathLocalee)) { + if ( + pathLocalee && + pathLocalee !== '' && + coveragesLocales.includes(pathLocalee) + ) { finalLocale = pathLocalee return finalLocale @@ -119,8 +128,14 @@ export const ssrGetFinalLocale = (path, headers: any, coveragesLocales: string[] // 2. LOCALE 쿠키 언어 (SSR에서는 headers에서 직접 파싱) const cookieHeader = headers.cookie || '' const cookies = parseCookies(cookieHeader) - const cookieLanguage = cookies.LOCALE ? `${cookies.LOCALE}`.toLowerCase() : '' - if (cookieLanguage && cookieLanguage !== '' && coveragesLocales.includes(cookieLanguage)) { + const cookieLanguage = cookies.LOCALE + ? `${cookies.LOCALE}`.toLowerCase() + : '' + if ( + cookieLanguage && + cookieLanguage !== '' && + coveragesLocales.includes(cookieLanguage) + ) { finalLocale = cookieLanguage return finalLocale } diff --git a/layers/utils/urlUtil.ts b/layers/utils/urlUtil.ts new file mode 100644 index 0000000..bceb487 --- /dev/null +++ b/layers/utils/urlUtil.ts @@ -0,0 +1,88 @@ +import { getHeader, getRequestHost, type H3Event } from 'h3' + +/** + * 게임 도메인을 가져오는 컴포저블 함수 + * 서버와 클라이언트 환경에서 모두 동작 + * @param event - H3 이벤트 객체 (서버 미들웨어에서 직접 전달 가능) + * @returns 게임 도메인 문자열 + */ +export const getGameDomain = (event?: H3Event): string => { + try { + let host = '' + + // 클라이언트 환경에서는 window.location.host를 사용 + if (import.meta.client) { + host = (window.location.host || '').split(':')[0] + } + + // 서버 환경에서는 event 객체를 사용 + if (import.meta.server) { + if (!event) return '' + + // 미들웨어에서 설정한 gameDomain이 있다면 우선 사용 + if (event.context.gameDomain) { + host = event.context.gameDomain + } else { + const serverHost = + getHeader(event, 'host') || getRequestHost(event) || '' + host = serverHost.split(':')[0] + } + } + + if (!host) return '' + + // dev2 호스트명인 경우 l9-dev.onstove.com을 사용 + if (host === 'samplegame-dev2.onstove.com') { + return 'l9-dev.onstove.com' + } + + return host + } catch (error) { + console.error('getGameDomain error:', error) + return '' + } +} + +/** + * URL에서 언어 코드를 추출하는 함수 + * @param url - URL 문자열 + * @returns 언어 코드 문자열 + */ +export const getPathLocale = (url?: string): string => { + const targetUrl = url || (import.meta.client ? window.location.pathname : '') + const cleanTargetUrl = targetUrl.endsWith('/') + ? targetUrl.slice(0, -1) + : targetUrl + + return cleanTargetUrl.split('/')[1] +} + +/** + * URL에서 언어 코드 이후의 경로를 추출하는 함수 + * @param url - URL 문자열 + * @returns 언어 코드 이후의 경로 문자열 + */ +export const getPathAfterLanguage = (url?: string): string => { + const targetUrl = url || (import.meta.client ? window.location.pathname : '') + const cleanTargetUrl = targetUrl.endsWith('/') + ? targetUrl.slice(0, -1) + : targetUrl + + // URL에서 언어 코드 패턴을 찾아서 그 뒤의 경로를 추출 + // 예: /ko/about/story -> /about/story + // 예: /ko -> "" (빈 문자열) + const languagePattern = /^\/[a-z]{2}(-[a-z]{2})?\/(.+)$/ + const match = cleanTargetUrl.match(languagePattern) + if (match && match[2]) { + return `/${match[2]}` + } else { + // 언어 코드만 있고 뒤에 아무것도 없는 경우 (예: /ko, /en, /zh-tw, /zh-cn) + const languageOnlyPattern = /^\/[a-z]{2}(-[a-z]{2})?$/ + if (languageOnlyPattern.test(cleanTargetUrl)) { + return '' + } else { + // 언어 코드가 없는 경우 원본 경로 그대로 반환 (이미 /로 시작) + return cleanTargetUrl + } + } +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 82095c9..84dc9df 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -6,14 +6,55 @@ export default defineNuxtConfig({ app: { head: { viewport: 'width=device-width, initial-scale=1, maximum-scale=5', + link: [ + // 폰트 CSS 비동기 로딩 + { + rel: 'preload', + href: 'https://static-cdn.onstove.com/resources/stds/stds-font-kr/stds-font-kr.css', + as: 'style', + onload: "this.onload=null;this.rel='stylesheet'", + }, + { + rel: 'preload', + href: 'https://static-cdn.onstove.com/resources/stds/stds-font-global/stds-font-global.css', + as: 'style', + onload: "this.onload=null;this.rel='stylesheet'", + }, + // { + // rel: 'preload', + // href: 'https://static-cdn.onstove.com/0.0.4/font-icon/StoveFont-Icon.css', + // as: 'style', + // onload: "this.onload=null;this.rel='stylesheet'", + // }, + ], + noscript: [ + // JavaScript 비활성화 시 폰트 CSS 로드 + { + tag: 'link', + rel: 'stylesheet', + href: 'https://static-cdn.onstove.com/resources/stds/stds-font-kr/stds-font-kr.css', + }, + { + tag: 'link', + rel: 'stylesheet', + href: 'https://static-cdn.onstove.com/resources/stds/stds-font-global/stds-font-global.css', + }, + // { + // tag: 'link', + // rel: 'stylesheet', + // href: 'https://static-cdn.onstove.com/0.0.4/font-icon/StoveFont-Icon.css', + // }, + ], script: [ { type: 'text/javascript', src: process.env.STOVE_GNB, + defer: true, }, { type: 'text/javascript', src: process.env.STOVE_81PLUG, + defer: true, }, { type: 'text/javascript', @@ -59,16 +100,10 @@ export default defineNuxtConfig({ // i18n 설정 - 런타임에 동적으로 설정됨 i18n: getI18n(), - experimental: { - payloadExtraction: false, - }, typescript: { typeCheck: true, strict: false, }, - nitro: { - prerender: { routes: [] }, - }, // [test] Nuxt가 pages 스캔하도록 명시 pages: true, @@ -113,4 +148,25 @@ export default defineNuxtConfig({ }, base: '/', }, + experimental: { + payloadExtraction: false, + }, + build: { + extractCSS: true, // CSS 추출 + analyze: false, // 트리 쉐이킹 활성화 + }, + nitro: { + prerender: { routes: [] }, + compressPublicAssets: true, + minify: true, + // 캐시 헤더 최적화 + routeRules: { + // 폰트: 1년 캐시 + '**/*.{woff,woff2,ttf,eot}': { + headers: { + 'Cache-Control': 'public, max-age=31536000', + }, + }, + }, + }, }) diff --git a/tailwind.config.ts b/tailwind.config.ts index 17fe0c2..b88378c 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -2,6 +2,22 @@ import type { Config } from 'tailwindcss' export default { content: ['./app/**/*.{js,vue,ts}', './layers/**/*.{js,vue,ts}'], + // 사용하지 않는 CSS 제거 (PurgeCSS) + purge: { + enabled: process.env.NODE_ENV === 'production', + content: ['./app/**/*.{js,vue,ts}', './layers/**/*.{js,vue,ts}'], + // 안전한 클래스 보호 + safelist: [ + /^theme-/, + /^bg-/, + /^text-/, + /^border-/, + /^hover:/, + /^md:/, + /^lg:/, + /^sm:/, + ], + }, theme: { extend: { screens: {