From 1003a01dee2953ddd0a6bd2ae730b2720239315e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9Chyeonggkim=E2=80=9D?=
<“hyeonggkim@smilegate.com”>
Date: Wed, 29 Oct 2025 20:56:58 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=90=EA=B2=80=20=EC=9E=91=EC=97=85?=
=?UTF-8?q?=20=EC=A4=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.npmrc | 2 +
.nvmrc | 1 +
app/pages/error.vue | 9 -
app/pages/inspection/index.vue | 325 ++++++++++++++++++
error.vue | 244 ++-----------
layers/composables/useGetGameMaintenance.ts | 97 ++++++
.../useGetInspectionDataExternal.ts | 69 ++++
layers/layouts/inspection.vue | 6 +
layers/middleware/inspection.ts | 42 +++
layers/middleware/pageData.global.ts | 14 +-
layers/plugins/error-handler.ts | 12 +
layers/server/api/clientIp.ts | 11 +
layers/server/middleware/gameData.ts | 56 ++-
layers/stores/inspectionStore.ts | 36 ++
layers/stores/useCallerInfoStore.ts | 13 +
layers/stores/useCommonStore.ts | 115 +++++++
layers/types/Common.ts | 22 +-
layers/types/DataizationType.ts | 101 ++++++
layers/types/GameMaintenanceType.ts | 44 +++
layers/types/InspectionType.ts | 38 ++
layers/utils/commonUtil.ts | 168 +++++++++
layers/utils/dataUtil.ts | 47 +++
package.json | 2 +
pnpm-lock.yaml | 38 +-
temp/middleware.ts | 286 +++++++++++++++
tsconfig.json | 2 +-
26 files changed, 1553 insertions(+), 247 deletions(-)
create mode 100644 .npmrc
delete mode 100644 app/pages/error.vue
create mode 100644 app/pages/inspection/index.vue
create mode 100644 layers/composables/useGetGameMaintenance.ts
create mode 100644 layers/composables/useGetInspectionDataExternal.ts
create mode 100644 layers/layouts/inspection.vue
create mode 100644 layers/middleware/inspection.ts
create mode 100644 layers/plugins/error-handler.ts
create mode 100644 layers/server/api/clientIp.ts
create mode 100644 layers/stores/inspectionStore.ts
create mode 100644 layers/stores/useCallerInfoStore.ts
create mode 100644 layers/stores/useCommonStore.ts
create mode 100644 layers/types/DataizationType.ts
create mode 100644 layers/types/GameMaintenanceType.ts
create mode 100644 layers/types/InspectionType.ts
create mode 100644 layers/utils/commonUtil.ts
create mode 100644 temp/middleware.ts
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..67673ab
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+@seed-next:registry=https://git.sginfra.net/api/v4/groups/4424/-/packages/npm/
+# @stove-ui:registry=https://git.sginfra.net/api/v4/projects/557/packages/npm/
diff --git a/.nvmrc b/.nvmrc
index 7af24b7..3bee07e 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1,2 @@
22.11.0
+
diff --git a/app/pages/error.vue b/app/pages/error.vue
deleted file mode 100644
index 1068f43..0000000
--- a/app/pages/error.vue
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
Dang
-
It looks like something broke.
-
Sorry about that.
-
-
-
\ No newline at end of file
diff --git a/app/pages/inspection/index.vue b/app/pages/inspection/index.vue
new file mode 100644
index 0000000..afc0268
--- /dev/null
+++ b/app/pages/inspection/index.vue
@@ -0,0 +1,325 @@
+
+
+
+
+
![logo]()
+
+
+
+
+
+
+ {{ webInspectionData.inspection_title1 }}
+
+
+ {{ tm('Inspection_Now_Maintenance') }}
+
+
+
+
+
+
+
{{ tm('Inspection_Maintenance_Time') }}
+
+
+ {{ getLocaleTimezone(locale) }}
+
+
+ {{ getLocaleTimezone('en', 'US') }}
+
+
+ {{ getLocaleTimezone('zh-tw', '') }}
+
+
+ {{ getLocaleTimezone('ja', '') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm('Inspection_Txt_Download') || '게임 다운로드' }}
+
+
+ {{ webInspectionData.inspection_content }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/error.vue b/error.vue
index dc92961..4055e3d 100644
--- a/error.vue
+++ b/error.vue
@@ -1,221 +1,25 @@
+
+
-
-
-
-
-
-
-
\ No newline at end of file
+
+
{{ error?.statusCode }}
+
{{ error?.statusMessage }}
+
+
+
+
+
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"]
}