feat: 점검 작업 중

This commit is contained in:
“hyeonggkim”
2025-10-29 20:56:58 +09:00
parent 7a22fa2287
commit 1003a01dee
26 changed files with 1553 additions and 247 deletions

2
.npmrc Normal file
View File

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

1
.nvmrc
View File

@@ -1 +1,2 @@
22.11.0 22.11.0

View File

@@ -1,9 +0,0 @@
<template>
<NuxtLayout>
<div>
<h1>Dang</h1>
<p>It looks like something broke.</p>
<p>Sorry about that.</p>
</div>
</NuxtLayout>
</template>

View File

@@ -0,0 +1,325 @@
<template>
<section class="inspection-section">
<!-- 로고 -->
<div v-if="isClient" class="inspection-logo">
<img :src="logoImgUrl" alt="logo" class="w-full h-full object-contain" />
</div>
<div class="inspection-content">
<!-- 점검 메시지 -->
<h1 class="inspection-title">
<template v-if="isClient && webInspectionData?.inspection_title1">
{{ webInspectionData.inspection_title1 }}
</template>
<template v-else>
{{ tm('Inspection_Now_Maintenance') }}
</template>
</h1>
<div class="inspection-cards">
<!-- 점검 시간 카드 -->
<div v-if="isClient && webInspectionData" class="inspection-card inspection-time-card">
<h2 class="card-title">{{ tm('Inspection_Maintenance_Time') }}</h2>
<div class="inspection-time">
<div class="time-row">
{{ getLocaleTimezone(locale) }}
</div>
<div class="time-row">
{{ getLocaleTimezone('en', 'US') }}
</div>
<div class="time-row">
{{ getLocaleTimezone('zh-tw', '') }}
</div>
<div class="time-row">
{{ getLocaleTimezone('ja', '') }}
</div>
</div>
</div>
<!-- 온스토브 & 다운로드 카드 -->
<div class="inspection-bottom-cards">
<!-- 온스토브 카드 -->
<div class="inspection-card inspection-stove-card">
<h3 v-dompurify-html="tm('Inspection_Game_During_Maintenance') || '홈페이지 점검 중에도 게임과 공식 커뮤니티는 그대로 이용할 수 있어요!'" class="card-title"></h3>
<div class="button-group">
<a
:href="communityUrl"
target="_blank"
rel="noopener noreferrer"
class="inspection-btn inspection-btn-outline"
>
<span>{{ tm('Inspection_Community_Btn') || '공식 커뮤니티' }}</span>
<AtomsIconsLongArrowRightLine :size="16" color="#1F1F1F" />
</a>
<button
class="inspection-btn inspection-btn-primary"
@click="handleGameStart"
>
<span>{{ tm('game_start_btn') || '게임 실행' }}</span>
<AtomsIconsLongArrowRightLine :size="16" color="#FFFFFF" />
</button>
</div>
</div>
<!-- 다운로드 카드 -->
<div v-if="isClient" class="inspection-card inspection-download-card">
<h3 class="card-title">
{{ tm('Inspection_Txt_Download') || '게임 다운로드' }}
</h3>
<div v-if="webInspectionData?.inspection_content" class="inspection-content-text">
{{ webInspectionData.inspection_content }}
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import {globalDateFormat} from '@seed-next/date';
import { formatDateOffset } from '#layers/utils/dataUtil'
import { useCheckGameStart } from '#layers/composables/useGameStart'
const config = useRuntimeConfig()
const rootPath = config.public.staticUrl
const runType = config.public.runType
const translationApi = `${rootPath}/${runType}/test`
const isClient = import.meta.client
const inspectionStore = useInspectionStore()
const { webInspectionData } = storeToRefs(inspectionStore)
const resultGetMultilingual = await useGetMultilingual({
baseApiUrl: translationApi,
fileName: 'test_common_inspection.json'
})
const { tm, locale } = useI18n({
useScope: 'local',
messages: Object(resultGetMultilingual.value.multilingual)
})
console.log("🚀 ~ globalDateFormat(new Date(webInspectionData.value?.ts_start_date || 0), 'ko'):", )
// locale에 따라 뒤에 KST 또는 UTC 추가 ko, en, zh-tw, ja
// ko: (KST)
// en: (UTC)
// zh-tw: 台灣時間 (KST)
// ja: (JST)
// 나머지: (KST)
const getLocaleTimezone = (localeType: string, region) => {
const tsStartDate = webInspectionData.value?.start_date || 0
const tsEndDate = webInspectionData.value?.end_date || 0
switch (localeType) {
case 'ko':
return `${globalDateFormat(new Date(tsStartDate), localeType, region || '', {useFullDate: true})} ~ ${globalDateFormat(new Date(tsEndDate), localeType, region || 'KR', {useFullDate: true})} (KST)`
case 'en':
return `${globalDateFormat(new Date(tsStartDate), localeType, region || '', {useFullDate: true})} ~ ${globalDateFormat(new Date(tsEndDate), localeType, region || '', {useFullDate: true})} (UTC)`
case 'zh-tw':
return `${globalDateFormat(new Date(tsStartDate), localeType, region || '', {useFullDate: true})} ~ ${globalDateFormat(new Date(tsEndDate), localeType, region || '', {useFullDate: true})} (台灣時間)`
case 'ja':
return `${globalDateFormat(new Date(tsStartDate), localeType, region || '', {useFullDate: true})} ~ ${globalDateFormat(new Date(tsEndDate), localeType, region || '', {useFullDate: true})} (JST)`
default:
return `${globalDateFormat(new Date(tsStartDate), localeType, region || '', {useFullDate: true})} ~ ${globalDateFormat(new Date(tsEndDate), localeType, region || '', {useFullDate: true})} (KST)`
}
}
// 날짜 포맷팅 함수 (CSR에서만 실행)
const formatInspectionTime = (timestamp: number, isKST: boolean): string => {
if (!import.meta.client || !timestamp) return ''
const lang = locale.value || 'ko'
const formatted = formatDateOffset({
ts: timestamp,
lang: isKST ? lang : 'en',
useSeconds: true,
useTimezone: true
})
// formatDateOffset의 결과를 YYYY.MM.DD, HH:MM:SS 형식으로 변환
// KST의 경우: YYYY-MM-DD HH:MM:SS (KST) -> YYYY.MM.DD, HH:MM:SS (KST)
// UTC의 경우: MM/DD/YYYY HH:MM:SS (UTC) -> YYYY.MM.DD, HH:MM:SS (UTC)
if (isKST) {
return formatted.replace(/-/g, '.').replace(' ', ', ')
} else {
// UTC 형식 변환: MM/DD/YYYY HH:MM:SS (UTC) -> YYYY.MM.DD, HH:MM:SS (UTC)
const parts = formatted.match(/(\d{2})\/(\d{2})\/(\d{4}) (.+)/)
if (parts) {
return `${parts[3]}.${parts[1]}.${parts[2]}, ${parts[4]}`
}
return formatted
}
}
const logoImgUrl = computed(() => {
// CSR에서만 처리
if (!import.meta.client || !webInspectionData.value) return ''
const currentLocale = locale.value || 'ko'
const localeData = (webInspectionData.value as any)?.[currentLocale]
if (localeData?.img_json?.bi_large) {
return localeData.img_json.bi_large
}
return webInspectionData.value.back_ground_image_url || ''
})
// 커뮤니티 URL
const communityUrl = computed(() => {
return '#'
})
// 게임 시작
const { validateLauncher } = useCheckGameStart()
const handleGameStart = () => {
validateLauncher()
}
definePageMeta({
middleware: ['inspection'],
layout: 'inspection',
showLoading: false
})
</script>
<style scoped>
.inspection-section {
@apply flex flex-col items-center gap-8 px-10 py-[120px] pb-[200px] min-h-screen;
background-color: #F0F0F0;
}
.inspection-logo {
@apply w-[944px] h-[150px] flex-shrink-0;
}
.inspection-logo img {
@apply w-full h-full object-contain;
}
.inspection-content {
@apply flex flex-col items-center gap-10 w-full max-w-[944px];
}
.inspection-title {
@apply text-center text-[24px] leading-[34px] font-bold tracking-[-0.72px] text-[#1F1F1F];
font-family: 'Spoqa Han Sans Neo', sans-serif;
}
.inspection-cards {
@apply flex flex-col gap-5 w-full;
}
.inspection-card {
@apply bg-white rounded-2xl p-8;
}
.inspection-time-card {
@apply flex flex-col items-center gap-4;
width: 944px;
min-height: 162px;
}
.card-title {
@apply text-center text-[20px] leading-[30px] font-bold tracking-[-0.6px] text-[#1F1F1F];
font-family: 'Spoqa Han Sans Neo', sans-serif;
}
.inspection-time {
@apply flex flex-col items-center gap-2;
}
.time-row {
@apply text-center text-[16px] leading-[26px] font-medium tracking-[-0.48px] text-[#1F1F1F];
font-family: 'Spoqa Han Sans Neo', sans-serif;
}
.inspection-bottom-cards {
@apply flex flex-row gap-5 w-full;
}
.inspection-stove-card,
.inspection-download-card {
@apply flex flex-col justify-between gap-4 flex-1;
}
.inspection-stove-card .card-title {
@apply text-left text-[18px] leading-[26px] font-bold tracking-[-0.54px] text-[#1F1F1F];
}
.inspection-download-card .card-title {
@apply text-left text-[18px] leading-[26px] font-bold tracking-[-0.54px] text-[#1F1F1F];
}
.inspection-content-text {
@apply text-left text-[16px] leading-[26px] font-medium tracking-[-0.48px] text-[#1F1F1F];
font-family: 'Spoqa Han Sans Neo', sans-serif;
white-space: pre-line;
}
.button-group {
@apply flex flex-row gap-3 w-full;
}
.inspection-btn {
@apply flex items-center justify-center gap-1 px-10 h-12 rounded-lg border border-black/10;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-size: 14px;
line-height: 20px;
font-weight: 500;
letter-spacing: -0.42px;
cursor: pointer;
transition: all 0.2s;
flex: 1;
}
.inspection-btn span {
@apply text-[#1F1F1F];
}
.inspection-btn-outline {
@apply bg-white;
}
.inspection-btn-outline:hover {
@apply bg-gray-50;
}
.inspection-btn-primary {
@apply bg-[#C7AE8B] border-[#C7AE8B];
}
.inspection-btn-primary span {
@apply text-white;
}
.inspection-btn-primary:hover {
@apply bg-[#B89D7A];
}
@media (max-width: 1024px) {
.inspection-section {
@apply px-5 py-20 pb-32;
}
.inspection-logo {
@apply w-full max-w-[944px];
}
.inspection-time-card {
@apply w-full;
}
.inspection-bottom-cards {
@apply flex-col;
}
.inspection-stove-card,
.inspection-download-card {
@apply w-full;
}
}
</style>

244
error.vue

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -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<WebInspectionData | null>(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 }
}

View File

@@ -0,0 +1,6 @@
<script setup lang="ts"></script>
<template>
<LayoutsHeader />
<slot />
</template>

View File

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

View File

@@ -16,6 +16,10 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v2.0/template/page` const apiUrl = `${stoveApiBaseUrl}/pub-comm/v2.0/template/page`
try { try {
if(to.matched) {
return
}
const pageUrl = getPathAfterLanguage(to.path) const pageUrl = getPathAfterLanguage(to.path)
// pageUrl이 빈값이거나 null이면 /brand로 리다이렉트 // pageUrl이 빈값이거나 null이면 /brand로 리다이렉트
@@ -36,9 +40,17 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
loading: true, loading: true,
})) as PageDataResponse | null })) 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) { if (response?.code === 0 && 'value' in response) {
store.setPageData(response.value) store.setPageData(response.value)
console.log('🚀 ~ pageData:', response.value) // console.log('🚀 ~ pageData:', response.value)
} else { } else {
store.clearPageData() store.clearPageData()
} }

View File

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

View File

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

View File

@@ -6,8 +6,13 @@ import {
} from 'h3' } from 'h3'
import { ssrGetFinalLocale } from '../../utils/localeUtil' import { ssrGetFinalLocale } from '../../utils/localeUtil'
import type { GameDataResponse } from '../../types/api/gameData' import type { GameDataResponse } from '../../types/api/gameData'
import type { ResGetInspectionData } from '../../types/InspectionType'
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const config = useRuntimeConfig()
const iBaseApiUrl = `${config.public.stoveApiUrlServer}`
const url = getRequestURL(event) const url = getRequestURL(event)
// 정적 자산, API, 파비콘 등은 제외하고 페이지 요청만 처리 // 정적 자산, API, 파비콘 등은 제외하고 페이지 요청만 처리
@@ -36,24 +41,12 @@ export default defineEventHandler(async event => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const stoveApiUrlServer = config.public.stoveApiUrlServer const stoveApiUrlServer = config.public.stoveApiUrlServer
const apiUrl = `${stoveApiUrlServer}/pub-comm/v1.0/template/game` const apiUrl = `${stoveApiUrlServer}/pub-comm/v1.0/template/game`
let inspectionData
const langCode = ssrGetFinalLocale( const langCode = ssrGetFinalLocale(
event?.node.req.url, event?.node.req.url,
event.node.req.headers 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<string, string> = { const queryParams: Record<string, string> = {
game_domain: event.context.gameDomain || '', game_domain: event.context.gameDomain || '',
lang_code: langCode, lang_code: langCode,
@@ -67,8 +60,43 @@ export default defineEventHandler(async event => {
event.context.gameData = response.value event.context.gameData = response.value
event.context.googleAnalyticsId = response.value?.ga_code 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<ResGetInspectionData>(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) { } catch (error) {
console.error('gameData load error:', error) console.error('gameData load error:', error)
} }

View File

@@ -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<WebInspectionData | null>(null) // 웹 점검 정보
const webInspectionStatus = ref<boolean | null>(null) // 웹 점검 상태
const gameMaintenanceData = ref<GameMaintenanceData | null>(null) // 게임 점검 정보
const gameMaintenanceStatus = ref<boolean | null>(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
}
})

View File

@@ -0,0 +1,13 @@
export const useCallerInfoStore = defineStore('callerInfoStore', () => {
const callerId = ref<string | null>('')
const callerDetail = ref<string | null>('')
const setCallerId = (paramCallerId: string | null) => {
callerId.value = paramCallerId
}
const setCallerDetail = (paramCalleDetail: string | null) => {
callerDetail.value = paramCalleDetail
}
return { callerId, callerDetail, setCallerId, setCallerDetail }
})

View File

@@ -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<DeviceMode>({
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<HTMLElement | null>(null)
const isLoading = ref<boolean>(true)
const isScrollLock = ref<boolean>(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
}
})

View File

@@ -1,5 +1,5 @@
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import type { StoveJsService } from '@/layers/types/Stove' import type { StoveJsService } from '#layers/types/Stove'
export type ClassType = HTMLAttributes['class'] export type ClassType = HTMLAttributes['class']
@@ -8,3 +8,23 @@ declare global {
stoveJsService?: StoveJsService 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<string, any>
}
export type { CommonRequestType, CommonResponseType, CommonPeriodType, ParsedCustomLinkOptions }

View File

@@ -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<string>
}
interface GnbType extends GnbMenuType {
depth2List?: Array<GnbMenuType>
}
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<CharacterCardType>
}
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<GnbType>
mainVideo: CommonPeriodType
promotionList?: Array<PromotionPreregistType>
characterList?: Array<CharacterType>
loreList?: Array<LoreType>
footerMenuList?: Array<FooterMenuType>
mediaList?: Array<MediaType>
sectionList?: Array<string>
marketList?: Array<MarketType>
}
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
}

View File

@@ -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<string> // 서비스 ID2(String Array), service_id1 전체를 설정할 경우 ["*"]로 등록해야 함.
type: string // 점검타입(REGULAR / TEMPORARY / URGENT)
languages: Array<Language> // 다국어 리스트 정보
description: string // 설명
start_at: number // UTC기준 점검 시작일(milli-timestamp(13digit))
end_at: number // UTC기준 점검 종료일(milli-timestamp(13digit))
}
interface DtoGameMaintenance {
total_count: number
list: Array<GameMaintenance>
}
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 }

View File

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

168
layers/utils/commonUtil.ts Normal file
View File

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

View File

@@ -154,3 +154,50 @@ export const getCurrentTimestamp = (unit: 'ms' | 's' = 'ms'): number => {
const now = Date.now() const now = Date.now()
return unit === 's' ? Math.floor(now / 1000) : 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}`
}
}

View File

@@ -29,6 +29,7 @@
"@nuxtjs/device": "^3.2.4", "@nuxtjs/device": "^3.2.4",
"@nuxtjs/i18n": "^10.0.6", "@nuxtjs/i18n": "^10.0.6",
"@pinia/nuxt": "^0.6.1", "@pinia/nuxt": "^0.6.1",
"@seed-next/date": "^0.0.0",
"@splidejs/splide": "^4.1.4", "@splidejs/splide": "^4.1.4",
"@splidejs/vue-splide": "^0.6.12", "@splidejs/vue-splide": "^0.6.12",
"@vueuse/core": "^13.6.0", "@vueuse/core": "^13.6.0",
@@ -55,6 +56,7 @@
"eslint-plugin-nuxt": "^4.0.0", "eslint-plugin-nuxt": "^4.0.0",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^10.4.0", "eslint-plugin-vue": "^10.4.0",
"lru-cache": "^11.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",

38
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@pinia/nuxt': '@pinia/nuxt':
specifier: ^0.6.1 specifier: ^0.6.1
version: 0.6.1(magicast@0.3.5)(typescript@5.9.2)(vue@3.5.21(typescript@5.9.2)) 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': '@splidejs/splide':
specifier: ^4.1.4 specifier: ^4.1.4
version: 4.1.4 version: 4.1.4
@@ -96,6 +99,9 @@ importers:
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^10.4.0 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))) 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: postcss:
specifier: ^8.5.6 specifier: ^8.5.6
version: 8.5.6 version: 8.5.6
@@ -106,7 +112,7 @@ importers:
specifier: ^3.4.17 specifier: ^3.4.17
version: 3.4.17 version: 3.4.17
typescript: typescript:
specifier: ^5.5.0 specifier: ^5.3.3
version: 5.9.2 version: 5.9.2
vue-tsc: vue-tsc:
specifier: ^3.0.7 specifier: ^3.0.7
@@ -302,6 +308,12 @@ packages:
peerDependencies: peerDependencies:
postcss-selector-parser: ^7.0.0 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': '@emnapi/core@1.5.0':
resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==}
@@ -1496,6 +1508,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@sindresorhus/is@7.0.2':
resolution: {integrity: sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==} resolution: {integrity: sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2288,6 +2303,9 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
db0@0.3.2: db0@0.3.2:
resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==} resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==}
peerDependencies: peerDependencies:
@@ -3246,6 +3264,10 @@ packages:
lru-cache@10.4.3: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 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: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -5087,6 +5109,10 @@ snapshots:
dependencies: dependencies:
postcss-selector-parser: 7.1.0 postcss-selector-parser: 7.1.0
'@date-fns/tz@1.4.1': {}
'@date-fns/utc@2.1.1': {}
'@emnapi/core@1.5.0': '@emnapi/core@1.5.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.1.0 '@emnapi/wasi-threads': 1.1.0
@@ -6253,6 +6279,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.50.0': '@rollup/rollup-win32-x64-msvc@4.50.0':
optional: true 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/is@7.0.2': {}
'@sindresorhus/merge-streams@2.3.0': {} '@sindresorhus/merge-streams@2.3.0': {}
@@ -7155,6 +7187,8 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
date-fns@4.1.0: {}
db0@0.3.2: {} db0@0.3.2: {}
de-indent@1.0.2: {} de-indent@1.0.2: {}
@@ -8147,6 +8181,8 @@ snapshots:
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@11.2.2: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1

286
temp/middleware.ts Normal file
View File

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

View File

@@ -14,6 +14,6 @@
"types/**/*", "types/**/*",
"layers/**/*", "layers/**/*",
"app/**/*" "app/**/*"
], , "temp/inspection.ts", "temp/middleware.ts" ],
"exclude": [".nuxt/types/**/*", "node_modules"] "exclude": [".nuxt/types/**/*", "node_modules"]
} }