From 641988b0335179c6110bab82cceac1490ae98d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chyeonggkim=E2=80=9D?= <“hyeonggkim@smilegate.com”> Date: Mon, 27 Oct 2025 16:06:32 +0900 Subject: [PATCH 1/8] refactor: update translation API and improve footer component logic --- i18n/locales/ko.ts | 11 ++-- layers/components/layouts/Footer.vue | 86 +++++++++++++++------------- layers/composables/useApiData.ts | 22 +++++++ 3 files changed, 73 insertions(+), 46 deletions(-) create mode 100644 layers/composables/useApiData.ts diff --git a/i18n/locales/ko.ts b/i18n/locales/ko.ts index e7f3c28..7074d95 100644 --- a/i18n/locales/ko.ts +++ b/i18n/locales/ko.ts @@ -1,10 +1,11 @@ export default defineI18nLocale(async (locale: string) => { //https://static-pubcomm.gate8.com/dev/test/multilingual/test_common_template.json?20251021185116 - const config = useRuntimeConfig() - const rootPath = config.public.staticUrl - const runType = config.public.runType + // const config = useRuntimeConfig() + // const rootPath = config.public.staticUrl + // const runType = config.public.runType - const translationApi = `${rootPath}/${runType}/test/multilingual/test_common_template.json` + // const translationApi = `${rootPath}/${runType}/test/multilingual/test_common_template.json` + const translationApi = `https://static-pubcomm.gate8.com/dev/test/multilingual/test_common_template.json` try { const { data } = await useFetch(translationApi, { @@ -15,7 +16,7 @@ export default defineI18nLocale(async (locale: string) => { }) // API 데이터에서 locale에 맞는 데이터를 추출 - const apiData = data.value?.[locale] || {} // locale에 맞는 데이터가 없으면 빈 객체 반환 + const apiData = data.value?.['ko'] || {} // locale에 맞는 데이터가 없으면 빈 객체 반환 // API 데이터와 common.json 데이터를 병합 (common.json이 우선순위) const finalResult = { ...apiData } diff --git a/layers/components/layouts/Footer.vue b/layers/components/layouts/Footer.vue index 51c4c30..97cf995 100644 --- a/layers/components/layouts/Footer.vue +++ b/layers/components/layouts/Footer.vue @@ -144,20 +144,31 @@ diff --git a/layers/components/layouts/Footer.vue b/layers/components/layouts/Footer.vue index 97cf995..259cb8a 100644 --- a/layers/components/layouts/Footer.vue +++ b/layers/components/layouts/Footer.vue @@ -97,7 +97,7 @@
From 470d8a62c774c69172110f4cad6222728240caa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chyeonggkim=E2=80=9D?= <“hyeonggkim@smilegate.com”> Date: Mon, 27 Oct 2025 21:18:12 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pages/error.vue | 9 + app/pages/index.vue | 2 + error.vue | 221 +++++++++++++++++ layers/components/blocks/LanguageSwitcher.vue | 224 ++++++++++++------ layers/components/layouts/Footer.vue | 157 ++++++++---- layers/middleware/init.route.global.ts | 2 +- layers/utils/dataUtil.ts | 2 +- public/images/common/img_error.png | Bin 0 -> 22819 bytes public/images/common/logo-stove.svg | 3 + 9 files changed, 494 insertions(+), 126 deletions(-) create mode 100644 app/pages/error.vue create mode 100644 error.vue create mode 100644 public/images/common/img_error.png create mode 100644 public/images/common/logo-stove.svg diff --git a/app/pages/error.vue b/app/pages/error.vue new file mode 100644 index 0000000..1068f43 --- /dev/null +++ b/app/pages/error.vue @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/pages/index.vue b/app/pages/index.vue index dceae28..43db7c4 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -7,6 +7,8 @@ const { pageData } = storeToRefs(pageDataStore) const currentLayout = computed(() => getLayoutType(pageData.value)) +console.log("🚀 ~ currentLayout:", currentLayout) + definePageMeta({ layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화 }) diff --git a/error.vue b/error.vue new file mode 100644 index 0000000..dc92961 --- /dev/null +++ b/error.vue @@ -0,0 +1,221 @@ + + + + + + \ No newline at end of file diff --git a/layers/components/blocks/LanguageSwitcher.vue b/layers/components/blocks/LanguageSwitcher.vue index 979af39..5fdc18f 100644 --- a/layers/components/blocks/LanguageSwitcher.vue +++ b/layers/components/blocks/LanguageSwitcher.vue @@ -1,14 +1,33 @@ @@ -74,17 +86,12 @@ const gameDataStore = useGameDataStore() // 사용 가능한 언어 목록 const availableLanguages = computed(() => { - return gameDataStore.gameData?.lang_codes || ['ko'] + return gameDataStore.gameData?.lang_codes?.map(localeCode => ({ + code: localeCode, + name: getLanguageName(localeCode) + })) || [{ code: 'ko', name: '한국어' }] }) -const { locale, setLocale } = useI18n() -const switchLocalePath = useSwitchLocalePath() -const pageDataStore = usePageDataStore() - -const selectedLocale = ref(locale.value) -const isChanging = ref(false) -const isDropdownOpen = ref(false) - // 언어 코드를 한국어 이름으로 변환하는 함수 const getLanguageName = (localeCode: string) => { const languageNames: Record = { @@ -103,18 +110,19 @@ const getLanguageName = (localeCode: string) => { return languageNames[localeCode] || localeCode } -// 현재 언어 이름 반환 -const getCurrentLanguageName = () => { - return getLanguageName(selectedLocale.value) -} +const { locale, setLocale } = useI18n() +const switchLocalePath = useSwitchLocalePath() +const pageDataStore = usePageDataStore() +const selectedLocale = ref(locale.value) +const isChanging = ref(false) +const isDropdownOpen = ref(false) // 드롭다운 토글 함수 const toggleDropdown = () => { if (!isChanging.value) { isDropdownOpen.value = !isDropdownOpen.value } } - // 언어 선택 함수 const selectLanguage = async (localeCode: string) => { if (localeCode === selectedLocale.value || isChanging.value) { @@ -122,12 +130,11 @@ const selectLanguage = async (localeCode: string) => { return } - selectedLocale.value = localeCode + selectedLocale.value = localeCode as any isDropdownOpen.value = false await switchLanguage() } - -// 언어 변경 함수 (CSR 방식) +// 언어 변경 함수 (서버 미드웨어를 통한 gameData 갱신) const switchLanguage = async () => { if (!selectedLocale.value || isChanging.value) return @@ -139,18 +146,26 @@ const switchLanguage = async () => { if (path) { // 페이지 데이터 초기화 (새로운 언어로 다시 로드되도록) pageDataStore.clearPageData() - + window.location.href = path // 언어 변경 및 라우팅 - await setLocale(selectedLocale.value as any) - // await router.push(path) + // await setLocale(selectedLocale.value as any) + + // 전체 페이지에 페이드 아웃 효과 적용 + // document.body.style.transition = 'opacity 0.1s ease-out' + // document.body.style.opacity = '0' + + // // 페이드 아웃 완료 후 페이지 이동 + // await new Promise(resolve => setTimeout(resolve, 100)) + + // 서버 미드웨어를 통해 gameData 갱신을 위해 페이지 새로고침 + // 이렇게 하면 서버 미드웨어가 새로운 언어로 gameData를 다시 가져옴 - // 페이지 새로고침을 통해 데이터 재로드 보장 - await nextTick() - window.location.reload() } } catch { // 오류 발생 시 이전 언어로 복원 selectedLocale.value = locale.value + // 페이드 효과 복원 + document.body.style.opacity = '1' } finally { isChanging.value = false } @@ -160,20 +175,85 @@ const switchLanguage = async () => { watch(locale, newLocale => { selectedLocale.value = newLocale }) - -// 외부 클릭 시 드롭다운 닫기 -onMounted(() => { - const handleClickOutside = (event: Event) => { - const target = event.target as HTMLElement - if (!target.closest('.relative')) { - isDropdownOpen.value = false - } - } - - document.addEventListener('click', handleClickOutside) - - onUnmounted(() => { - document.removeEventListener('click', handleClickOutside) - }) -}) + \ No newline at end of file diff --git a/layers/components/layouts/Footer.vue b/layers/components/layouts/Footer.vue index 259cb8a..e927e8c 100644 --- a/layers/components/layouts/Footer.vue +++ b/layers/components/layouts/Footer.vue @@ -24,12 +24,12 @@
  • -

    {{ tm('Footer_AgeRating') }}

    +

    {{ footerAgeRating }}

    +
    + + + diff --git a/layers/composables/useGetGameMaintenance.ts b/layers/composables/useGetGameMaintenance.ts new file mode 100644 index 0000000..a9835b3 --- /dev/null +++ b/layers/composables/useGetGameMaintenance.ts @@ -0,0 +1,97 @@ +import type { ReqGameMaintenance, ResGameMaintenance } from '#layers/types/GameMaintenanceType' + +/** + * 게임 점검 + */ +const useGetGameMaintenance = () => { + const inspectionStore = useInspectionStore() + const logPrefix = { + exception: '[Exception] /composables/useGetGameMaintenance', + failure: '[Failure] /composables/useGetGameMaintenance' + } + const isGameMaintenance = ref(false) // 게임 서버 점검 여부 + + // [Setter] 게임 서버 점검 여부 세팅 + const setIsGameMaintenance = (status: boolean) => { + isGameMaintenance.value = status + } + + // 게임 점검이 아닌 경우 일괄 세팅 + const setGameMaintenanceFalse = () => { + setIsGameMaintenance(false) + inspectionStore.setGameMaintenanceStatus(false) + inspectionStore.setGameMaintenanceData({ ts_start_date: 0, ts_end_date: 0, detail_link: '' }) + } + + /** + * 게임 서버 점검 여부 + * + * @param {ReqGameMaintenance} req + * @description https://wiki.smilegate.net/pages/viewpage.action?pageId=362619887 + */ + const checkGameMaintenance = async (req: ReqGameMaintenance) => { + let res: ResGameMaintenance = {} as ResGameMaintenance + try { + const baseApiUrl = req.baseApiUrl || '' + + // Path Variables + const category = req.category || 'GAME' + const serviceId1 = req.service_id1 || '' + const lang = req.lang || 'ko' + + const url = `${baseApiUrl}/v2.0/maintenances/${category}/${serviceId1}/${lang}` + + res = (await commonFetch('GET', url, {})) as ResGameMaintenance + + if (res != null && res.code === 0) { + // FIXME: 테스트용 데이터 --------------------------------------------------- + /* const config = useRuntimeConfig() + if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) { + res.value = { + total_count: 1, + list: [ + { + start_at: new Date().getTime(), + end_at: new Date().getTime(), + languages: [{ link: 'https://www.onstove.com', lang: 'ko', title: '', content: '' }], + maintenance_no: 0, + category: '', + service_id1: '', + service_id2: [], + type: '', + description: '' + } + ] + } + } */ + // ------------------------------------------------------------------------ + if (Number(res.value?.total_count) > 0 && res.value?.list != null && res.value?.list.length > 0) { + setIsGameMaintenance(true) // 서버 1개 이상 점검일 경우 점검 중으로 간주 + inspectionStore.setGameMaintenanceData({ + ts_start_date: res.value?.list[0].start_at || 0, + ts_end_date: res.value?.list[0].end_at || 0, + detail_link: res.value?.list[0].languages[0].link || '' + }) + inspectionStore.setGameMaintenanceStatus(true) + } else { + setGameMaintenanceFalse() + } + } else { + // [500] 내부 서버 에러 + // [70001] 부적절한 엑세스 토큰 + // [70051] 부적절한 파라미터 요청 - {param_key} + // [70052] 데이터를 찾을 수 않음 + setGameMaintenanceFalse() + } + } catch (e) { + console.error(`${logPrefix.exception}.checkGameMaintenance: `, e) + res = { code: -99999, message: `${e}` } + setGameMaintenanceFalse() + } + return res + } + + return { isGameMaintenance, checkGameMaintenance } +} + +export { useGetGameMaintenance } diff --git a/layers/composables/useGetInspectionDataExternal.ts b/layers/composables/useGetInspectionDataExternal.ts new file mode 100644 index 0000000..c3a2e46 --- /dev/null +++ b/layers/composables/useGetInspectionDataExternal.ts @@ -0,0 +1,69 @@ +import type { WebInspectionData, ReqGetInspectionData, ResGetInspectionData } from '#layers/types/InspectionType' + +/** + * 웹 점검 + */ +export const useGetInspectionDataExternal = () => { + const inspectionStore = useInspectionStore() + const logPrefix = { + exception: '[Exception] /composables/useGetInspectionDataExternal', + failure: '[Failure] /composables/useGetInspectionDataExternal' + } + const webInspectionData = ref(null) + const isWebInspection = ref(false) // 웹 점검 여부 + + // [Setter] 웹 점검 여부 세팅 + const setIsWebInspection = (status: boolean) => { + isWebInspection.value = status + } + + /** + * 웹 점검 여부 + * + * @param {ReqGetInspectionData} req + * @description https://wiki.smilegate.net/pages/viewpage.action?pageId=563198067 + */ + const getInspectionDataExternal = async (req: ReqGetInspectionData) => { + // const config = useRuntimeConfig() + const apiUrl = `${req.baseApiUrl}/pub-comm/v3.0/inspection/${req.gameId}` + + try { + const response = (await commonFetch('GET', apiUrl)) as ResGetInspectionData + + // FIXME: 테스트용 데이터 --------------------------------------------------- + /* if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) { + response.value = { + inspection_status: 1, + inspection: { + inspection_status: 1, + start_date: '2025-09-19 10:00:00', + end_date: '2025-09-19 12:00:00', + ts_start_date: new Date().getTime(), + ts_end_date: new Date().getTime(), + back_ground_image_type: 'image', + back_ground_image_url: 'https://www.onstove.com', + inspection_title1: '', + inspection_title2: '' + } + } + } */ + // ------------------------------------------------------------------------ + + if (response?.value && response.value.inspection) { + webInspectionData.value = response.value.inspection + isWebInspection.value = response.value.inspection_status === 1 + + inspectionStore.setWebInspectionData(webInspectionData.value) + inspectionStore.setWebInspectionStatus(isWebInspection.value) + } + } catch (e) { + console.error(`${logPrefix.exception}.getInspectionDataExternal: `, e) + } + + if (webInspectionData.value !== null) { + setIsWebInspection(isWebInspection.value) + } + } + + return { webInspectionData, isWebInspection, getInspectionDataExternal } +} diff --git a/layers/layouts/inspection.vue b/layers/layouts/inspection.vue new file mode 100644 index 0000000..ddd7e8c --- /dev/null +++ b/layers/layouts/inspection.vue @@ -0,0 +1,6 @@ + + + diff --git a/layers/middleware/inspection.ts b/layers/middleware/inspection.ts new file mode 100644 index 0000000..e1ecbd4 --- /dev/null +++ b/layers/middleware/inspection.ts @@ -0,0 +1,42 @@ +export default defineNuxtRouteMiddleware(async (to) => { + try { + if (import.meta.client) { + const config = useRuntimeConfig() + // const baseDomain = `${config.public.baseDomain}` + const stoveApiUrl = `${config.public.stoveApiUrl}` + console.log("🚀 ~ stoveApiUrl:", stoveApiUrl) + // const stoveGameId = `${config.public.stoveGameId}` + // const stoveMaintenanceApiUrl = `${config.public.stoveMaintenanceApiUrl}` + + /* const localeCookie = useCookie('LOCALE', { + domain: baseDomain + }) */ + + // const finalLocale = csrGetFinalLocale(to.path) + // localeCookie.value = finalLocale.toUpperCase() + + // 웹 점검 ----- + const { isWebInspection, getInspectionDataExternal } = useGetInspectionDataExternal() + await getInspectionDataExternal({ baseApiUrl: stoveApiUrl, gameId: 'STOVE_LORD' }) + + // 게임 점검 ----- + // const { checkGameMaintenance } = useGetGameMaintenance() + // await checkGameMaintenance({ + // baseApiUrl: stoveMaintenanceApiUrl, + // category: 'GAME', + // service_id1: stoveGameId, + // lang: `${finalLocale}`.toLowerCase() + // }) + + if (isWebInspection.value && !to.path.includes('inspection') && !to.path.includes('api')) { + // 점검 중인 경우 + // return navigateTo(`/${finalLocale}/inspection`, { external: true }) + } else if (!isWebInspection.value && to.path?.indexOf('inspection') !== -1) { + // 점검이 종료된 후 점검 페이지 접근시 메인으로 리다이렉트 + // return navigateTo(`/${finalLocale}`, { external: true }) + } + } + } catch (e) { + console.error('[Exception] /middleware/inspection: ', e) + } +}) diff --git a/layers/middleware/pageData.global.ts b/layers/middleware/pageData.global.ts index af563db..abb60d9 100644 --- a/layers/middleware/pageData.global.ts +++ b/layers/middleware/pageData.global.ts @@ -16,6 +16,10 @@ export default defineNuxtRouteMiddleware(async (to, _from) => { const apiUrl = `${stoveApiBaseUrl}/pub-comm/v2.0/template/page` try { + if(to.matched) { + return + } + const pageUrl = getPathAfterLanguage(to.path) // pageUrl이 빈값이거나 null이면 /brand로 리다이렉트 @@ -36,9 +40,17 @@ export default defineNuxtRouteMiddleware(async (to, _from) => { loading: true, })) as PageDataResponse | null + console.log("🚀 ~ response?.code:", response?.code) + if(response?.code === 91003) { + throw createError({ + statusCode: 404, + statusMessage: 'Page not found', + }) + } + if (response?.code === 0 && 'value' in response) { store.setPageData(response.value) - console.log('🚀 ~ pageData:', response.value) + // console.log('🚀 ~ pageData:', response.value) } else { store.clearPageData() } diff --git a/layers/plugins/error-handler.ts b/layers/plugins/error-handler.ts new file mode 100644 index 0000000..54a402a --- /dev/null +++ b/layers/plugins/error-handler.ts @@ -0,0 +1,12 @@ +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.vueApp.config.errorHandler = (error, instance, info) => { + console.log("🚀 000000 ~ error:", error) + // handle error, e.g. report to a service + } + + // Also possible + nuxtApp.hook('vue:error', (error, instance, info) => { + console.log("🚀1111 ~ error:", error) + // handle error, e.g. report to a service + }) + }) \ No newline at end of file diff --git a/layers/server/api/clientIp.ts b/layers/server/api/clientIp.ts new file mode 100644 index 0000000..25a2958 --- /dev/null +++ b/layers/server/api/clientIp.ts @@ -0,0 +1,11 @@ +import { getTrueClientIp } from '#layers/utils/apiUtil' + +export default defineEventHandler((event) => { + let clientIP = '' + try { + clientIP = getTrueClientIp(event.node.req) + } catch (e) { + console.error('[Exception] /server/api/clientIp - Cannot Get Client IP: ', e) + } + return clientIP || '' +}) diff --git a/layers/server/middleware/gameData.ts b/layers/server/middleware/gameData.ts index 3857a86..480b2cc 100644 --- a/layers/server/middleware/gameData.ts +++ b/layers/server/middleware/gameData.ts @@ -6,8 +6,13 @@ import { } from 'h3' import { ssrGetFinalLocale } from '../../utils/localeUtil' import type { GameDataResponse } from '../../types/api/gameData' +import type { ResGetInspectionData } from '../../types/InspectionType' export default defineEventHandler(async event => { + + const config = useRuntimeConfig() + const iBaseApiUrl = `${config.public.stoveApiUrlServer}` + const url = getRequestURL(event) // 정적 자산, API, 파비콘 등은 제외하고 페이지 요청만 처리 @@ -36,24 +41,12 @@ export default defineEventHandler(async event => { const config = useRuntimeConfig() const stoveApiUrlServer = config.public.stoveApiUrlServer const apiUrl = `${stoveApiUrlServer}/pub-comm/v1.0/template/game` - + let inspectionData const langCode = ssrGetFinalLocale( event?.node.req.url, event.node.req.headers ) - // URL의 첫 번째 path를 lang_code로 사용 (파비콘, API 경로 제외) - // const pathSegments = url.pathname - // .split('/') - // .filter( - // segment => - // segment && - // !segment.includes('favicon') && - // !segment.includes('api') && - // !segment.startsWith('_') - // ) - // const langCode = pathSegments[0] || 'ko' - const queryParams: Record = { game_domain: event.context.gameDomain || '', lang_code: langCode, @@ -67,8 +60,43 @@ export default defineEventHandler(async event => { event.context.gameData = response.value event.context.googleAnalyticsId = response.value?.ga_code - console.log('🚀 ~ gameData:', response.value) + // console.log('🚀 ~ gameData:', response.value) + + // 점검 데이터 조회 + if (response.value.game_id) { + 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) + + if (inspectionData?.inspection_status === 0 ) { + /** + * 점검 중인 경우 + * - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인ㄹ + * - 점검 URL 경로가 아닐 경우 no-cache 설정 + * - 화이트 리스트 체크 + */ + // 현재 경로가 점검 페이지가 아닐 경우 리다이렉트 + const inspectionPath = `/${langCode}/inspection` + + if (!url.pathname.includes('/inspection')) { + event.node.res.statusCode = 302 + event.node.res.setHeader('Location', inspectionPath) + event.node.res.end() + return + } + } + + } } + } catch (error) { console.error('gameData load error:', error) } diff --git a/layers/stores/inspectionStore.ts b/layers/stores/inspectionStore.ts new file mode 100644 index 0000000..12b3aa2 --- /dev/null +++ b/layers/stores/inspectionStore.ts @@ -0,0 +1,36 @@ +import type { WebInspectionData } from '#layers/types/InspectionType' +import type { GameMaintenanceData } from '#layers/types/GameMaintenanceType' + +export const useInspectionStore = defineStore('inspection', () => { + const webInspectionData = ref(null) // 웹 점검 정보 + const webInspectionStatus = ref(null) // 웹 점검 상태 + const gameMaintenanceData = ref(null) // 게임 점검 정보 + const gameMaintenanceStatus = ref(null) // 게임 점검 상태 + + const setWebInspectionData = (data: WebInspectionData) => { + webInspectionData.value = data + } + + const setWebInspectionStatus = (status: boolean) => { + webInspectionStatus.value = status + } + + const setGameMaintenanceData = (data: GameMaintenanceData) => { + gameMaintenanceData.value = data + } + + const setGameMaintenanceStatus = (status: boolean) => { + gameMaintenanceStatus.value = status + } + + return { + webInspectionData, + webInspectionStatus, + gameMaintenanceData, + gameMaintenanceStatus, + setWebInspectionData, + setWebInspectionStatus, + setGameMaintenanceData, + setGameMaintenanceStatus + } +}) diff --git a/layers/stores/useCallerInfoStore.ts b/layers/stores/useCallerInfoStore.ts new file mode 100644 index 0000000..368b3dd --- /dev/null +++ b/layers/stores/useCallerInfoStore.ts @@ -0,0 +1,13 @@ +export const useCallerInfoStore = defineStore('callerInfoStore', () => { + const callerId = ref('') + const callerDetail = ref('') + + const setCallerId = (paramCallerId: string | null) => { + callerId.value = paramCallerId + } + const setCallerDetail = (paramCalleDetail: string | null) => { + callerDetail.value = paramCalleDetail + } + + return { callerId, callerDetail, setCallerId, setCallerDetail } +}) diff --git a/layers/stores/useCommonStore.ts b/layers/stores/useCommonStore.ts new file mode 100644 index 0000000..d19df92 --- /dev/null +++ b/layers/stores/useCommonStore.ts @@ -0,0 +1,115 @@ +import { defineStore } from 'pinia' +import { useWindowSize, useWindowScroll } from '@vueuse/core' + +interface DeviceMode { + mode: 'desktop' | 'mobile' + browser: 'chrome' | 'crawler' | 'edge' | 'firefox' | 'safari' | null + isDesktop: boolean + isMobile: boolean + isTablet: boolean + isIos: boolean + isAndroid: boolean + isDeviceReady: boolean +} + +export const useCommonStore = defineStore('commonStore', () => { + const stoveGnbHeight = 48 + + const useDeviceData = useDevice() + const { width: windowWidth, height: windowHeight } = useWindowSize() + const { x: windowX, y: windowY } = useWindowScroll({ behavior: 'smooth' }) + + const device = ref({ + mode: useDeviceData.isMobile || useDeviceData.isTablet ? 'mobile' : 'desktop', + browser: useDeviceData.isChrome + ? 'chrome' + : useDeviceData.isCrawler + ? 'crawler' + : useDeviceData.isEdge + ? 'edge' + : useDeviceData.isFirefox + ? 'firefox' + : useDeviceData.isSafari + ? 'safari' + : null, + isDesktop: useDeviceData.isDesktop, + isMobile: useDeviceData.isMobile, + isTablet: useDeviceData.isTablet, + isIos: useDeviceData.isIos, + isAndroid: useDeviceData.isAndroid, + isDeviceReady: false + }) + + const isPassedStoveGnb = ref(false) + const scrollFixedXValue = ref('0px') + const footerRef = ref(null) + const isLoading = ref(true) + const isScrollLock = ref(false) + + const updateDeviceMode = () => { + device.value.mode = useDeviceData.isMobile || useDeviceData.isTablet ? 'mobile' : 'desktop' + device.value.browser = useDeviceData.isChrome + ? 'chrome' + : useDeviceData.isCrawler + ? 'crawler' + : useDeviceData.isEdge + ? 'edge' + : useDeviceData.isFirefox + ? 'firefox' + : useDeviceData.isSafari + ? 'safari' + : null + device.value.isDesktop = useDeviceData.isDesktop + device.value.isMobile = useDeviceData.isMobile + device.value.isTablet = useDeviceData.isTablet + device.value.isIos = useDeviceData.isIos + device.value.isAndroid = useDeviceData.isAndroid + device.value.isDeviceReady = true + } + + const updateIsPassedStoveGnb = () => { + isPassedStoveGnb.value = windowY.value >= stoveGnbHeight + + if (isPassedStoveGnb.value) { + scrollFixedXValue.value = `-${windowX.value}px` + } else { + scrollFixedXValue.value = '0px' + } + } + + const isLoadingComplete = () => { + isLoading.value = false + } + + const scrollLock = () => { + isScrollLock.value = !isScrollLock.value + } + + const addScrollLock = () => { + isScrollLock.value = true + } + + const removeScrollLock = () => { + isScrollLock.value = false + } + + return { + device, + windowWidth, + windowHeight, + windowX, + windowY, + isPassedStoveGnb, + scrollFixedXValue, + footerRef, + isLoading, + isScrollLock, + + updateDeviceMode, + updateIsPassedStoveGnb, + isLoadingComplete, + scrollLock, + addScrollLock, + removeScrollLock + } +}) diff --git a/layers/types/Common.ts b/layers/types/Common.ts index 42d5031..4ef5537 100644 --- a/layers/types/Common.ts +++ b/layers/types/Common.ts @@ -1,5 +1,5 @@ import type { HTMLAttributes } from 'vue' -import type { StoveJsService } from '@/layers/types/Stove' +import type { StoveJsService } from '#layers/types/Stove' export type ClassType = HTMLAttributes['class'] @@ -8,3 +8,23 @@ declare global { stoveJsService?: StoveJsService } } +interface CommonRequestType { + baseApiUrl: string + gameId: string +} + +interface CommonResponseType { + code?: number + message?: string +} + +interface CommonPeriodType { + startDate?: string + endDate?: string +} +interface ParsedCustomLinkOptions { + tm: (key: string) => { txt: string } + query?: Record +} + +export type { CommonRequestType, CommonResponseType, CommonPeriodType, ParsedCustomLinkOptions } diff --git a/layers/types/DataizationType.ts b/layers/types/DataizationType.ts new file mode 100644 index 0000000..07faafd --- /dev/null +++ b/layers/types/DataizationType.ts @@ -0,0 +1,101 @@ +import type { PromotionPreregistType } from '@/types/promotion/PreregistType' +import type { CommonPeriodType } from '@/types/CommonType' + +// [S] Type in czn_homepage_brand_siteConfig.json ---------------------------------------- +interface GnbMenuType { + id: string + title: string + link: string + target: string + displayLocales?: Array +} + +interface GnbType extends GnbMenuType { + depth2List?: Array +} + +interface SnsType { + id: string + title: string + link: string + sub: string + key?: string + log?: object +} + +interface LoreType { + loreNo: number + chapter: number // 1 : 프롤로그, 2 ~ : N장 + title: string + description: string +} + +interface CharacterCardType { + id: string +} + +interface CharacterType { + id: string + cardList: Array +} + +interface FooterMenuType { + id: string + title: string + link: string + target: string + active: string +} + +interface MediaType { + id: string + title: string + logCode?: string +} + +interface MarketType { + id: string + code: string + link: string +} + +// [E] Type in czn_homepage_brand_siteConfig.json ---------------------------------------- +interface ReqGetDataization { + baseApiUrl: string + fileName?: string +} + +interface DataizationType { + gnbList?: Array + mainVideo: CommonPeriodType + promotionList?: Array + characterList?: Array + loreList?: Array + footerMenuList?: Array + mediaList?: Array + sectionList?: Array + marketList?: Array +} + +interface ResGetDataization { + code: number + message: string + value?: { + dataization?: DataizationType + } +} + +export type { + // [S] Type in czn_homepage_brand_siteConfig.json ---------------------------------------- + GnbType, + SnsType, + MediaType, + LoreType, + PromotionPreregistType, + FooterMenuType, + MarketType, + // [E] Type in czn_homepage_brand_siteConfig.json ---------------------------------------- + DataizationType, + ReqGetDataization, + ResGetDataization +} diff --git a/layers/types/GameMaintenanceType.ts b/layers/types/GameMaintenanceType.ts new file mode 100644 index 0000000..c7dae68 --- /dev/null +++ b/layers/types/GameMaintenanceType.ts @@ -0,0 +1,44 @@ +import type { CommonRequestType, CommonResponseType } from './Common' + +/************************************************************************* + * 게임 점검 + ************************************************************************/ +interface ReqGameMaintenance extends CommonRequestType { + // Path Variables + category: string + service_id1: string + lang: string +} +interface Language { + lang: string + title: string + content: string + link: string +} +interface GameMaintenance { + maintenance_no: number // 점검 번호 + category: string // 카테고리 + service_id1: string // 서비스 ID1 + service_id2: Array // 서비스 ID2(String Array), service_id1 전체를 설정할 경우 ["*"]로 등록해야 함. + type: string // 점검타입(REGULAR / TEMPORARY / URGENT) + languages: Array // 다국어 리스트 정보 + description: string // 설명 + start_at: number // UTC기준 점검 시작일(milli-timestamp(13digit)) + end_at: number // UTC기준 점검 종료일(milli-timestamp(13digit)) +} +interface DtoGameMaintenance { + total_count: number + list: Array +} +interface ResGameMaintenance extends CommonResponseType { + value?: DtoGameMaintenance + error?: string +} + +// 게임 점검 데이터 +interface GameMaintenanceData { + ts_start_date: number // 게임 점검 시작 타임스탬프 + ts_end_date: number // 게임 점검 종료 타임스탬프 + detail_link?: string // 게임 점검 공지 링크 +} +export type { ReqGameMaintenance, ResGameMaintenance, GameMaintenanceData } diff --git a/layers/types/InspectionType.ts b/layers/types/InspectionType.ts new file mode 100644 index 0000000..69ed9ae --- /dev/null +++ b/layers/types/InspectionType.ts @@ -0,0 +1,38 @@ +import type { CommonRequestType, CommonResponseType } from './Common' + +/************************************************************************* + * 웹 점검 + ************************************************************************/ +interface WebInspectionData { + inspection_status: number // 점검 상태 (0: 정상, 1: 점검 중) (단순 운영툴 설정 점검 값) + start_date: string // 점검 시작 날짜 (문자열 형식) + end_date: string // 점검 종료 날짜 (문자열 형식) + ts_start_date: number // 점검 시작 타임스탬프 + ts_end_date: number // 점검 종료 타임스탬프 + back_ground_image_type?: string // 배경 이미지 타입 (0: 없음, 기타 값: 특정 타입) + back_ground_image_url?: string // 배경 이미지 URL + movie_yn?: string // 동영상 사용 여부 ("Y" 또는 "N") + movie_url?: string // 동영상 URL + inspection_title_type?: string // 점검 제목 타입 + inspection_title1: string // 점검 제목 1 + inspection_title2: string // 점검 제목 2 + inspection_content?: string // 점검 내용 + + // Internal ----- + ip_filter_use_yn?: string // IP 필터 사용 여부 ("Y" 또는 "N") + ip_filter_list?: string[] // 허용된 IP 목록 +} + +interface ReqGetInspectionData extends CommonRequestType { + // do nothing +} + +interface DtoGetInspectionData { + inspection_status?: number // 점검 여부 + 점검 시간 + 화이트 리스트 고려하여 계산된 결과 + inspection?: WebInspectionData +} +interface ResGetInspectionData extends CommonResponseType { + value?: DtoGetInspectionData +} + +export type { WebInspectionData, ReqGetInspectionData, ResGetInspectionData } diff --git a/layers/utils/commonUtil.ts b/layers/utils/commonUtil.ts new file mode 100644 index 0000000..1d22531 --- /dev/null +++ b/layers/utils/commonUtil.ts @@ -0,0 +1,168 @@ +import type { ParsedCustomLinkOptions } from '@/types/CommonType' + +/** + * 페이지 - 유효성 체크 + * + * @param {number} page - 페이지 + * @param {number} totalPage - 총 페이지 수 + */ +const checkPageValidation = (page: number, totalPage: number) => { + // 최소, 최대 범위 체크 + if (page < 1) { + page = 1 + } else if (page > totalPage) { + page = totalPage + } + return page +} + +/** + * 파일 다운로드 함수 + * + * @param {string} fileUrl - 다운로드할 파일의 URL + * @param {string} fileName - 저장할 파일 이름 (옵션) + */ +const csrDownloadFile = (fileUrl: string, fileName?: string) => { + const link = document.createElement('a') + link.href = fileUrl + + // 파일 이름이 제공되면 다운로드 이름 설정 + if (fileName) { + link.download = fileName + } + + // 링크를 클릭하여 다운로드 트리거 + document.body.appendChild(link) + link.click() + + // DOM에서 링크 제거 + document.body.removeChild(link) +} + +/** + * 마케팅 코드 조회 + */ +const csrGetMarketingCode = () => { + const route = useRoute() + const mcode = Number(`${route.query.mcode != null && route.query.mcode !== '' ? route.query.mcode : ''}`) + return isNaN(mcode) ? undefined : mcode +} + +/** + * 외부 링크 이동 (새 창) + * + * @param {string} link - 이동할 외부 링크 + */ +const csrGoExternalLink = (link: string = '') => { + window.open(link, '_blank') +} + +/** + * QA용 국가 코드 조회 + */ +const csrGetQc = () => { + const route = useRoute() + const qc = `${route.query.qc != null && route.query.qc !== '' ? route.query.qc : ''}` + return qc +} + +/** + * 문자열이 숫자인지 확인 + * + * @param {string} str - 확인할 문자열 + */ +const isNumeric = (str: string): boolean => { + return /^-?\d+(\.\d+)?$/.test(str) +} + +/** + * 가공된 링크 파싱 + * + * @param {string} link - 원본 링크 + * @param {Function} tm - i18n의 tm 함수 (예: (key) => ({ txt: string })) + * @param {any} query - 추가 쿼리 파라미터 + */ +const getParsedCustomLink = (link: string, { tm, query = {} }: ParsedCustomLinkOptions) => { + const config = useRuntimeConfig() + let result = `${link || ''}` + + // @c{key} 패턴 치환 (예: @c{stoveCommunityUrl}) + if (link.includes('@c')) { + result = result.replace(/@c\{(.*?)\}/g, (_, key) => { + // config.public에서 해당 key 값을 찾아 치환 + return typeof config.public[key] === 'string' ? config.public[key] : '' + }) + } + + // @m{key} 패턴 치환 (예: @m{Community_Channel_Key}) + if (link.includes('@m')) { + result = result.replace(/@m\{(.*?)\}/g, (_, key) => { + // tm 함수로 변환하여 치환 + return tm(key)?.txt ?? '' + }) + } + + // @q{key} 패턴 치환 (예: @q{ppid}) + if (link.includes('@q')) { + result = result.replace(/@q\{(.*?)\}/g, (_, key) => { + let q = '' + if (query[key]) { + q += result.includes('?') ? '&' : '?' + q += `${key}=${query[key]}` + } + return q + }) + } + return result +} + +/** + * 쿠키 설정 - 만료기간 하루 단위 셋팅 + * + * @param {string} name - 쿠키 이름 + * @param {string} value - 쿠키 값 + * @param {number} exp - 만료기간 (옵션) + */ +const setCookieForDay = (name: string, value: string, exp?: number) => { + const date = new Date() + if (!exp) { + exp = 1 + } + date.setTime(date.getTime() + exp * 24 * 60 * 60 * 1000) + + const setCookie = useCookie(name, { + expires: new Date(date), + path: '/' + }) + + setCookie.value = value +} + +// 정적 파일인지 확인하는 함수 +const isStaticFile = (path: string): boolean => { + return /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|scss)$/i.test(path) +} + +/** + * 기준값이 최솟값 ~ 최댓값에 속하는지 확인 + * + * @param {number} ref - 기준값 + * @param {number} min - 최솟값 + * @param {number} max - 최댓값 + */ +const isInRange = (ref: number, min: number, max: number): boolean => { + return ref >= min && ref <= max +} + +export { + checkPageValidation, + csrDownloadFile, + csrGetMarketingCode, + csrGoExternalLink, + csrGetQc, + isNumeric, + getParsedCustomLink, + setCookieForDay, + isStaticFile, + isInRange +} diff --git a/layers/utils/dataUtil.ts b/layers/utils/dataUtil.ts index 7c3d66d..b9fa47b 100644 --- a/layers/utils/dataUtil.ts +++ b/layers/utils/dataUtil.ts @@ -154,3 +154,50 @@ export const getCurrentTimestamp = (unit: 'ms' | 's' = 'ms'): number => { const now = Date.now() return unit === 's' ? Math.floor(now / 1000) : now } + + +export const formatDateOffset = ({ + ts, + lang, + useSeconds, + useTimezone +}: { + ts: number + lang: string + useSeconds?: boolean + useTimezone?: boolean +}) => { + const offset = { ko: 9, ja: 9, 'zh-tw': 8, en: 0 }[lang] || 0 + const date = new Date(ts + offset * 3600000) + const pad = (n: number) => String(n).padStart(2, '0') + + const year = date.getUTCFullYear() + const month = date.getUTCMonth() + 1 + const day = date.getUTCDate() + const hours = date.getUTCHours() + const minutes = date.getUTCMinutes() + const seconds = date.getUTCSeconds() + + if (lang === 'ko') { + let format = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}` + format += useSeconds ? `:${pad(seconds)}` : '' + format += useTimezone ? ' (KST)' : '' + return `${format}` + } else if (lang === 'zh-tw') { + let format = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}` + format += useSeconds ? `:${pad(seconds)}` : '' + format += useTimezone ? ` (UTC${offset > 0 ? '+' + offset : ''})` : '' + return `${format}` + } else if (lang === 'ja') { + let format = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}` + format += useSeconds ? `:${pad(seconds)}` : '' + format += useTimezone ? ' (日本時間)' : '' + return `${format}` + } else { + //= en + let format = `${pad(month)}/${pad(day)}/${year} ${pad(hours)}:${pad(minutes)}` + format += useSeconds ? `:${pad(seconds)}` : '' + format += useTimezone ? ' (UTC)' : '' + return `${format}` + } +} diff --git a/package.json b/package.json index 6f980b3..b3ad0ff 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@nuxtjs/device": "^3.2.4", "@nuxtjs/i18n": "^10.0.6", "@pinia/nuxt": "^0.6.1", + "@seed-next/date": "^0.0.0", "@splidejs/splide": "^4.1.4", "@splidejs/vue-splide": "^0.6.12", "@vueuse/core": "^13.6.0", @@ -55,6 +56,7 @@ "eslint-plugin-nuxt": "^4.0.0", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^10.4.0", + "lru-cache": "^11.1.0", "postcss": "^8.5.6", "prettier": "^3.6.2", "tailwindcss": "^3.4.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92b9f42..336f762 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@pinia/nuxt': specifier: ^0.6.1 version: 0.6.1(magicast@0.3.5)(typescript@5.9.2)(vue@3.5.21(typescript@5.9.2)) + '@seed-next/date': + specifier: ^0.0.0 + version: 0.0.0 '@splidejs/splide': specifier: ^4.1.4 version: 4.1.4 @@ -96,6 +99,9 @@ importers: eslint-plugin-vue: specifier: ^10.4.0 version: 10.4.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(vue-eslint-parser@10.2.0(eslint@9.35.0(jiti@2.5.1))) + lru-cache: + specifier: ^11.1.0 + version: 11.2.2 postcss: specifier: ^8.5.6 version: 8.5.6 @@ -106,7 +112,7 @@ importers: specifier: ^3.4.17 version: 3.4.17 typescript: - specifier: ^5.5.0 + specifier: ^5.3.3 version: 5.9.2 vue-tsc: specifier: ^3.0.7 @@ -302,6 +308,12 @@ packages: peerDependencies: postcss-selector-parser: ^7.0.0 + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + + '@date-fns/utc@2.1.1': + resolution: {integrity: sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA==} + '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} @@ -1496,6 +1508,9 @@ packages: cpu: [x64] os: [win32] + '@seed-next/date@0.0.0': + resolution: {integrity: sha1-d6+dtjsFjxR4SGWSuBpJrxZCgwc=, tarball: https://git.sginfra.net/api/v4/projects/3708/packages/npm/@seed-next/date/-/@seed-next/date-0.0.0.tgz} + '@sindresorhus/is@7.0.2': resolution: {integrity: sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==} engines: {node: '>=18'} @@ -2288,6 +2303,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + db0@0.3.2: resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==} peerDependencies: @@ -3246,6 +3264,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5087,6 +5109,10 @@ snapshots: dependencies: postcss-selector-parser: 7.1.0 + '@date-fns/tz@1.4.1': {} + + '@date-fns/utc@2.1.1': {} + '@emnapi/core@1.5.0': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -6253,6 +6279,12 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.0': optional: true + '@seed-next/date@0.0.0': + dependencies: + '@date-fns/tz': 1.4.1 + '@date-fns/utc': 2.1.1 + date-fns: 4.1.0 + '@sindresorhus/is@7.0.2': {} '@sindresorhus/merge-streams@2.3.0': {} @@ -7155,6 +7187,8 @@ snapshots: csstype@3.1.3: {} + date-fns@4.1.0: {} + db0@0.3.2: {} de-indent@1.0.2: {} @@ -8147,6 +8181,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 diff --git a/temp/middleware.ts b/temp/middleware.ts new file mode 100644 index 0000000..03cd7f3 --- /dev/null +++ b/temp/middleware.ts @@ -0,0 +1,286 @@ +import { LRUCache } from 'lru-cache' +// import { DEFAULT_LOCALE_COVERAGES } from '@/i18n.config' +import { getTrueClientIp } from '#layers/utils/apiUtil' +import { ssrGetFinalLocale } from '#layers/utils/localeUtil' +import type { ResGetInspectionData, WebInspectionData } from '#layers/types/InspectionType' +import { isStaticFile } from '#layers/utils/commonUtil' + + +console.log("🚀 ~ setCacheHeaders ~ event.node.res.setHeader:") +/** + * 캐시 제어 헤더를 설정하는 공통 함수 + * + * @param event - 이벤트 객체 + * @param cacheMode - 캐시 모드 설정 ('no-cache', 'short', 'medium', 'default') + * @param customMaxAge - 커스텀 max-age 값 (초 단위) + */ +function setCacheHeaders( + event: { node: { res: { setHeader: (name: string, value: string) => void } } }, + cacheMode: 'no-cache' | 'short' | 'medium' | 'default', + customMaxAge?: number +): void { + // 원래 setHeader 함수 참조 저장 + const originalSetHeader = event.node.res.setHeader + + // Cache-Control 헤더 설정값 결정 + let cacheControl: string + switch (cacheMode) { + case 'no-cache': + cacheControl = 'no-cache, no-store, must-revalidate' + // no-cache 모드일 때는 추가 헤더도 설정 + event.node.res.setHeader('Pragma', 'no-cache') + event.node.res.setHeader('Expires', '0') + break + case 'short': + cacheControl = `public, max-age=${customMaxAge || 10}` + break + case 'medium': + cacheControl = `public, max-age=${customMaxAge || 15}` + break + case 'default': + default: + cacheControl = `public, max-age=${customMaxAge || 60}` + break + } + + // Cache-Control 헤더를 강제로 설정하기 위해 setHeader 메소드 오버라이드 + event.node.res.setHeader = function (name: string, value: string) { + if (name.toLowerCase() === 'cache-control') { + return originalSetHeader.call(this, name, cacheControl) + } + return originalSetHeader.call(this, name, value) + } + + // 바로 캐시 제어 헤더 적용 +} + +const cache = new LRUCache({ + max: 100, // 캐시에 저장할 최대 항목 수 + ttl: 1000 * 30 // 30초 동안 캐시 유지 +}) + + +/** + * 최종 언어 쿠키 세팅 + * + * @param event - 이벤트 객체 + * @param finalLocale - 최종 언어 + * @param baseDomain - 기본 도메인 + */ +function setFinalLocaleCookie(event: any, finalLocale: string, baseDomain: string) { + setCookie(event, 'LOCALE', finalLocale.toUpperCase(), { + domain: baseDomain, + path: '/', + maxAge: 60 * 60 * 24 * 365 // 1년 (초 단위) + }) +} + +/** + * Locale Middleware 역할 함수 + * + * @param event - 이벤트 객체 + * @param finalLocale - 최종 언어 + */ +function fnLocaleMiddleware(event: any, 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 pathLocale = arrPath.length > 1 ? arrPath[1] : '' + + // URL에서 현재 언어와 최종 언어가 다르면 리다이렉트 + if (pathLocale !== finalLocale) { + let newLocalePath = '' + if (pathLocale === '') { + newLocalePath = `/${finalLocale}` + } else { + arrPath[1] = finalLocale + newLocalePath = arrPath.join('/') + } + + if (queryString !== '') { + newLocalePath += `?${queryString}` + } + + event.node.res.statusCode = 302 + event.node.res.setHeader('Location', newLocalePath) + event.node.res.end() + } +} + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const runType = `${config.public.runType}` + const iBaseApiUrl = `${config.public.stoveApiUrlServer}` + const gameId = `${event.context.gameData?.game_id}` + const baseDomain = `${config.public.baseDomain}` + + if (['local', 'local-gate8', 'dev'].includes(runType)) { + // Sandbox 이상 환경에서만 동작 및 확인 가능 (local, dev는 통과 처리) + try { + // 언어 코드 추출 + const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers) + setFinalLocaleCookie(event, finalLocale, baseDomain) + + // ------------------------------------------------------------------------------- + // [Locale Middleware] + // ------------------------------------------------------------------------------- + fnLocaleMiddleware(event, finalLocale) + } catch (e) { + console.error('[Exception] /server/middleware/middleware-global: ', e) + } + } else { + // ------------------------------------------------------------------------------- + // [Inspection Middleware] + // ------------------------------------------------------------------------------- + const fullPath = event.path + + // 1-1. 정적 파일 패스 + if (isStaticFile(event.path)) { + return + } + + // 1-2. /inspection 패스 + if (fullPath.includes('/inspection')) { + // 리턴 되기 전 언어 쿠키 세팅 + const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers) + setFinalLocaleCookie(event, finalLocale, baseDomain) + return + } + + // 1-3. 특정 경로 패스 (API, 리소스) + if ( + fullPath.startsWith('/api/') || + fullPath.startsWith('/_nuxt/') || + fullPath.includes('/assets/') || + fullPath.includes('favicon') + ) { + return + } + + // 캐시 키 생성 + const cacheKey = 'inspection' + + try { + // 2. 언어 코드 추출 + const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers) + setFinalLocaleCookie(event, finalLocale, baseDomain) + + // 초기화 + let inspectionData + + // 3. 캐시된 데이터가 없거나 만료되었을 때만 API 호출 + if (cache.has(cacheKey)) { + inspectionData = cache.get(cacheKey) as WebInspectionData + } else { + const apiUrl = `${iBaseApiUrl}/pub-comm/v3.0/inspection/${gameId}` + // 직접 $fetch 사용 (composable 사용하지 않음) + const response = await $fetch(apiUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + inspectionData = response?.value?.inspection as WebInspectionData + console.log("🚀 00000 inspectionData:", inspectionData) + cache.set(cacheKey, inspectionData) // 캐시에 저장 + } + + // 4. 현재 시간과 점검 기간 비교 + const currentTime = Date.now() + const tsStartDate = inspectionData?.ts_start_date || 0 + const tsEndDate = inspectionData?.ts_end_date || 0 + const timeUntilInspectionSeconds = Math.floor((tsStartDate - currentTime) / 1000) + + // 5. 점검 상태별 캐시 설정 + if (inspectionData?.inspection_status === 1 && currentTime >= tsStartDate && currentTime <= tsEndDate) { + /** + * 점검 중인 경우 + * - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인 + * - 점검 URL 경로가 아닐 경우 no-cache 설정 + * - 화이트 리스트 체크 + */ + // 점검 url path 가 아닐 경우, no-cache 설정 + const inspectionPath = `/${finalLocale}/inspection` + if (fullPath !== inspectionPath) { + setCacheHeaders(event, 'no-cache') + } + + // 점검 중일 때 IP 필터링 활성화 여부 확인 + if (inspectionData?.ip_filter_use_yn === 'Y') { + const clientIP = getTrueClientIp(event.node.req) + + // 허용된 IP 목록 확인 + if (!inspectionData?.ip_filter_list?.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 { + event.node.res.statusCode = 302 + event.node.res.setHeader('Location', inspectionPath) + event.node.res.end() + } + } else { + /** + * 점검이 아닌 경우 + * - 홈 경로는 no-cache + * - 점검 예정 시간에 따른 캐시 설정 + * - 점검 5분 전: 짧은 캐시 (10초) + * - 점검 30분 전: 중간 캐시 (15초) + * - 점검 30분 이후: 기본 캐시 (60초) + */ + // 홈 경로: 캐시 없음 + const isHomePath = [ + '', + '/' + //, ...Object.values(DEFAULT_LOCALE_COVERAGES).flatMap((locale) => [`/${locale}`, `/${locale}/`]) + ].includes(fullPath) + + if (isHomePath) { + setCacheHeaders(event, 'no-cache') + } else { + // 점검 예정 시간에 따른 캐시 설정 + + 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') + } + } + } + // ------------------------------------------------------------------------------- + // [Locale Middleware] + // ------------------------------------------------------------------------------- + fnLocaleMiddleware(event, finalLocale) + } + + // 정상 접속 허용 + } catch (e) { + console.error('[Exception] /server/middleware/middleware-02-global: ', e) + } + } +}) diff --git a/tsconfig.json b/tsconfig.json index 51f3aea..698edbb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,6 @@ "types/**/*", "layers/**/*", "app/**/*" - ], +, "temp/inspection.ts", "temp/middleware.ts" ], "exclude": [".nuxt/types/**/*", "node_modules"] } From 5c43a3e838b2fc044653c9cf1c08ffa36f87d602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chyeonggkim=E2=80=9D?= <“hyeonggkim@smilegate.com”> Date: Thu, 30 Oct 2025 18:31:24 +0900 Subject: [PATCH 5/8] fix: disable TypeScript type checking and update inspection page layout --- app/pages/inspection/index.vue | 138 ++++++++++++++++++--------- layers/server/middleware/gameData.ts | 4 +- layers/types/DataizationType.ts | 3 +- layers/types/InspectionType.ts | 1 + layers/utils/commonUtil.ts | 2 +- nuxt.config.ts | 2 +- 6 files changed, 97 insertions(+), 53 deletions(-) diff --git a/app/pages/inspection/index.vue b/app/pages/inspection/index.vue index afc0268..44e1bfe 100644 --- a/app/pages/inspection/index.vue +++ b/app/pages/inspection/index.vue @@ -40,24 +40,31 @@
    -

    -
    +

    +
    {{ tm('Inspection_Community_Btn') || '공식 커뮤니티' }} - + 게임 시작 + + + + + +
    @@ -66,8 +73,18 @@

    {{ tm('Inspection_Txt_Download') || '게임 다운로드' }}

    -
    - {{ webInspectionData.inspection_content }} +
    +
    @@ -77,7 +94,7 @@ diff --git a/layers/server/middleware/gameData.ts b/layers/server/middleware/gameData.ts index 480b2cc..b94dac8 100644 --- a/layers/server/middleware/gameData.ts +++ b/layers/server/middleware/gameData.ts @@ -60,7 +60,7 @@ export default defineEventHandler(async event => { event.context.gameData = response.value event.context.googleAnalyticsId = response.value?.ga_code - // console.log('🚀 ~ gameData:', response.value) + console.log('🚀 ~ gameData:', response.value) // 점검 데이터 조회 if (response.value.game_id) { @@ -76,7 +76,7 @@ export default defineEventHandler(async event => { inspectionData = inspectionResponse?.value?.inspection // console.log("🚀 ~ inspectionData:", inspectionData) - if (inspectionData?.inspection_status === 0 ) { + if (inspectionData?.inspection_status === 0) { /** * 점검 중인 경우 * - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인ㄹ diff --git a/layers/types/DataizationType.ts b/layers/types/DataizationType.ts index 07faafd..a667182 100644 --- a/layers/types/DataizationType.ts +++ b/layers/types/DataizationType.ts @@ -1,5 +1,4 @@ -import type { PromotionPreregistType } from '@/types/promotion/PreregistType' -import type { CommonPeriodType } from '@/types/CommonType' +import type { CommonPeriodType } from '#layers/types/Common' // [S] Type in czn_homepage_brand_siteConfig.json ---------------------------------------- interface GnbMenuType { diff --git a/layers/types/InspectionType.ts b/layers/types/InspectionType.ts index 69ed9ae..846c84d 100644 --- a/layers/types/InspectionType.ts +++ b/layers/types/InspectionType.ts @@ -21,6 +21,7 @@ interface WebInspectionData { // Internal ----- ip_filter_use_yn?: string // IP 필터 사용 여부 ("Y" 또는 "N") ip_filter_list?: string[] // 허용된 IP 목록 + launching_status?: number // 런칭 여부 (0: 런칭 전, 1: 런칭 후) } interface ReqGetInspectionData extends CommonRequestType { diff --git a/layers/utils/commonUtil.ts b/layers/utils/commonUtil.ts index 1d22531..fb42881 100644 --- a/layers/utils/commonUtil.ts +++ b/layers/utils/commonUtil.ts @@ -1,4 +1,4 @@ -import type { ParsedCustomLinkOptions } from '@/types/CommonType' +import type { ParsedCustomLinkOptions } from '#layers/types/Common' /** * 페이지 - 유효성 체크 diff --git a/nuxt.config.ts b/nuxt.config.ts index 29d7b5f..02de4e9 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -63,7 +63,7 @@ export default defineNuxtConfig({ payloadExtraction: false, }, typescript: { - typeCheck: true, + typeCheck: false, strict: false, }, nitro: { From 74851e01ed9bdcbca49cf869cac8cb4831cbfff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chyeonggkim=E2=80=9D?= <“hyeonggkim@smilegate.com”> Date: Fri, 31 Oct 2025 15:30:06 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EC=A0=90=EA=B2=80=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80,=20=EC=96=B8=EC=96=B4?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pages/[d1]/[d2]/[d3].vue | 1 + app/pages/[d1]/[d2]/index.vue | 1 + app/pages/[d1]/index.vue | 1 + app/pages/index.vue | 2 + app/pages/inspection/index.vue | 272 ++++++++-------- layers/components/blocks/LanguageSwitcher.vue | 43 ++- layers/components/layouts/Footer.vue | 91 ++---- layers/middleware/inspection.ts | 21 +- layers/middleware/pageData.global.ts | 14 +- layers/server/middleware/gameData.ts | 302 ++++++++++++++++-- layers/server/plugins/nitroPlugin.ts | 68 ++++ layers/types/Common.ts | 31 +- layers/utils/localeUtil.ts | 138 ++++---- 13 files changed, 629 insertions(+), 356 deletions(-) create mode 100644 layers/server/plugins/nitroPlugin.ts diff --git a/app/pages/[d1]/[d2]/[d3].vue b/app/pages/[d1]/[d2]/[d3].vue index dceae28..6d1ae69 100644 --- a/app/pages/[d1]/[d2]/[d3].vue +++ b/app/pages/[d1]/[d2]/[d3].vue @@ -9,6 +9,7 @@ const currentLayout = computed(() => getLayoutType(pageData.value)) definePageMeta({ layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화 + middleware: ['inspection'] }) diff --git a/app/pages/[d1]/[d2]/index.vue b/app/pages/[d1]/[d2]/index.vue index dceae28..6d1ae69 100644 --- a/app/pages/[d1]/[d2]/index.vue +++ b/app/pages/[d1]/[d2]/index.vue @@ -9,6 +9,7 @@ const currentLayout = computed(() => getLayoutType(pageData.value)) definePageMeta({ layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화 + middleware: ['inspection'] }) diff --git a/app/pages/[d1]/index.vue b/app/pages/[d1]/index.vue index dceae28..6d1ae69 100644 --- a/app/pages/[d1]/index.vue +++ b/app/pages/[d1]/index.vue @@ -9,6 +9,7 @@ const currentLayout = computed(() => getLayoutType(pageData.value)) definePageMeta({ layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화 + middleware: ['inspection'] }) diff --git a/app/pages/index.vue b/app/pages/index.vue index 43db7c4..6db7172 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -11,7 +11,9 @@ console.log("🚀 ~ currentLayout:", currentLayout) definePageMeta({ layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화 + middleware: ['inspection'] }) +