fix. 서버 미들웨어, 미들웨어 수정

This commit is contained in:
clkim
2025-12-19 17:39:46 +09:00
parent 4ca299be4a
commit 1d936966ae
25 changed files with 592 additions and 798 deletions

View File

@@ -1,79 +0,0 @@
# 타입 변경 검증 리포트
## 변경 사항
`NodeJS.Timeout` 타입을 `ReturnType<typeof setTimeout>`으로 변경하여 더 범용적으로 사용 가능하도록 개선했습니다.
## 변경된 파일
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<typeof setTimeout>` 타입을 정상적으로 받을 수 있음
- 브라우저 환경: `clearTimeout(number)`
- Node.js 환경: `clearTimeout(NodeJS.Timeout)`
### ✅ 사용 패턴 검증
모든 파일에서 다음 패턴이 정상적으로 작동함:
```typescript
// 타입 선언
const timeoutId = ref<ReturnType<typeof setTimeout> | null>(null)
// setTimeout 사용
timeoutId.value = setTimeout(() => {
// ...
}, delay)
// clearTimeout 사용
if (timeoutId.value) {
clearTimeout(timeoutId.value) // ✅ 정상 작동
timeoutId.value = null
}
```
## 타입 비교
### 이전: `NodeJS.Timeout`
- **장점**: Node.js 환경에서 명확한 타입
- **단점**: 브라우저 환경에서 타입 불일치 가능성
- **문제**: 브라우저에서는 `setTimeout``number`를 반환하므로 타입 에러 발생 가능
### 변경 후: `ReturnType<typeof setTimeout>`
- **장점**:
- 환경에 따라 자동으로 적절한 타입 선택
- 브라우저: `number`
- Node.js: `NodeJS.Timeout`
- 범용적이고 유연함
- **단점**: 없음
- **결과**: 모든 환경에서 정상 작동
## 검증 결과
### ✅ 타입 안정성
- TypeScript 컴파일러가 모든 타입을 정상적으로 추론
- 타입 에러 없음
### ✅ 런타임 호환성
- `clearTimeout`이 모든 환경에서 정상 작동
- 브라우저와 Node.js 모두 지원
### ✅ 코드 품질
- 더 범용적이고 유지보수하기 쉬운 타입
- 환경 의존성 제거
## 결론
**✅ 변경 완료 및 검증 통과**
`ReturnType<typeof setTimeout>` 타입으로 변경해도 문제가 없으며, 오히려 더 범용적이고 안전한 타입입니다. 모든 타입 체크를 통과했고, `clearTimeout`과의 호환성도 확인되었습니다.

View File

@@ -23,7 +23,7 @@ const metaData = ref<GameDataMetaTag | null>(null)
// SSR에서 게임 데이터 가져오기
const getGameDataFromServer = (): GameDataValue | null => {
return import.meta.server
? (nuxtApp.ssrContext?.event.context.gameData ?? null)
? nuxtApp.ssrContext?.event?.context?.gameData
: null
}

View File

@@ -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<PageDataValue | null>(null)
const currentLayout = ref<'default' | 'promotion' | null>(null)
onMounted(() => {
const pageDataStore = usePageDataStore()

View File

@@ -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<PageDataValue | null>(null)
const currentLayout = ref<'default' | 'promotion' | null>(null)
onMounted(() => {
const pageDataStore = usePageDataStore()

View File

@@ -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<PageDataValue | null>(null)
const currentLayout = ref<'default' | 'promotion' | null>(null)
onMounted(() => {
const pageDataStore = usePageDataStore()

View File

@@ -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<PageDataValue | null>(null)
const currentLayout = ref<'default' | 'promotion' | null>(null)
onMounted(() => {
const pageDataStore = usePageDataStore()

View File

@@ -79,13 +79,17 @@
<div v-if="launchingStatus" class="flex flex-1">
<BlocksButtonLauncher
v-if="Number(gameData?.platform_type) !== 2 && device.isDesktop || Number(gameData?.platform_type) === 1"
v-if="
(Number(gameData?.platform_type) !== 2 &&
device.isDesktop) ||
Number(gameData?.platform_type) === 1
"
type="custom"
platform="pc"
class="inspection-btn inspection-btn-primary w-full color-black text-sm md:text-base flex-1"
>
<span>{{ tm('Txt_Game_Start') }}</span>
<svg
width="16"
height="16"
@@ -103,7 +107,7 @@
</BlocksButtonLauncher>
<AtomsButtonVariant
v-else-if="Number(gameData?.platform_type) !== 1"
v-else-if="Number(gameData?.platform_type) !== 1"
type="custom"
class="inspection-btn inspection-btn-primary w-full color-black text-sm md:text-base flex-1"
@click="handleGameStart"
@@ -125,10 +129,7 @@
/>
</svg>
</AtomsButtonVariant>
</div>
</div>
</div>
</div>
@@ -139,7 +140,11 @@
:class="`platform-type-${gameData?.platform_type}`"
>
<h3 class="card-title text-base md:text-lg">
{{ t('Inspection_Txt_Download', { gameName: gameData?.game_name }) }}
{{
t('Inspection_Txt_Download', {
gameName: gameData?.game_name,
})
}}
</h3>
<div class="flex flex-row gap-3 flex-wrap">
<BlocksButtonLauncher
@@ -166,7 +171,6 @@
<script setup lang="ts">
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) {

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -41,8 +41,23 @@ const imagePaths = computed(() => {
<picture v-if="isResponsiveMode">
<source media="(min-width: 1024px)" :srcset="imagePaths.pc" />
<source media="(max-width: 1023px)" :srcset="imagePaths.mo" />
<img :src="imagePaths.pc" :alt="alt" v-bind="$attrs" loading="lazy" />
<img
:src="imagePaths.pc"
:alt="alt"
v-bind="$attrs"
loading="lazy"
decoding="async"
fetchpriority="auto"
/>
</picture>
<img v-else :src="imagePaths.mo" :alt="alt" v-bind="$attrs" loading="lazy" />
<img
v-else
:src="imagePaths.mo"
:alt="alt"
v-bind="$attrs"
loading="lazy"
decoding="async"
fetchpriority="auto"
/>
</template>

View File

@@ -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',

View File

@@ -14,11 +14,12 @@ const props = defineProps<Props>()
const mainContentRef = ref<HTMLElement>()
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()
},
})
}
})
</script>

View File

@@ -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 ''
}
}

View File

@@ -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,
}
}

View File

@@ -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' },
})
)
}
})

View File

@@ -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)
}

View File

@@ -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<string, string>
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()
})

View File

@@ -0,0 +1,10 @@
export default defineNuxtPlugin(() => {
const router = useRouter()
const modalStore = useModalStore()
router.beforeEach((to, from) => {
if (to.path !== from.path) {
modalStore.handleResetModalAll()
}
})
})

View File

@@ -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<string, WebInspectionData>({
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<string, string> = {
game_domain: cleanHost || '',
lang_code: '',
game_domain: gameDomain || '',
lang_code: getPathLocale(event?.node.req.url),
}
const initResponse = await $fetch<GameDataResponse>(apiUrl, {
const response = await $fetch<GameDataResponse>(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<string, string> = {
game_domain: cleanHost || '',
lang_code: finalLocale,
}
const response = await $fetch<GameDataResponse>(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<string, string> = {
game_domain: cleanHost || '',
lang_code: finalLocale,
}
// 1-3. 특정 경로 패스 (API, 리소스)
if (shouldSkipPath(fullPath)) return
const response = await $fetch<GameDataResponse>(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<ResGetInspectionData>(
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<ResGetInspectionData>(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,
})
}
})

View File

@@ -2,19 +2,25 @@ import type { GameDataValue } from '#layers/types/api/gameData'
export const useGameDataStore = defineStore('gameData', () => {
const gameData = ref<GameDataValue | null>(null)
const langCode = ref<string | null>(null)
const langCodes = ref<string[] | null>(null)
const defaultLangCode = ref<string | null>(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,
}

View File

@@ -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,
}
})

View File

@@ -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
}

88
layers/utils/urlUtil.ts Normal file
View File

@@ -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
}
}
}

View File

@@ -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',
},
},
},
},
})

View File

@@ -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: {