Files
web-temp/layers/server/middleware/gameData.ts

351 lines
12 KiB
TypeScript

import { LRUCache } from 'lru-cache'
import { defineEventHandler, createError, type H3Event } from 'h3'
import { ISO_LANGUAGE_CODES } from '@/i18n.config'
import { getGameDomain, getPathLocale, parseUrl } from '#layers/utils/urlUtil'
import { ssrGetFinalLocale } from '#layers/utils/localeUtil'
import { isStaticFile } from '#layers/utils/commonUtil'
import { getTrueClientIp } from '#layers/utils/apiUtil'
import type { GameDataResponse } from '#layers/types/api/gameData'
import type {
ResGetInspectionData,
WebInspectionData,
} from '#layers/types/InspectionType'
/**
* 캐시 제어 헤더를 설정하는 공통 함수
*
* @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)
}
// 바로 캐시 제어 헤더 적용
event.node.res.setHeader('Cache-Control', cacheControl)
}
const cache = new LRUCache<string, WebInspectionData>({
max: 100, // 캐시에 저장할 최대 항목 수
ttl: 1000 * 30, // 30초 동안 캐시 유지
})
/**
* Locale Middleware 역할 함수
* URL의 언어 코드를 최종 언어로 변경하거나 추가
* template에서는 i18n 버전 때문에 사용 필요. (동일 코드 init.route.global -> csr에서만 실행.)
*/
function fnLocaleMiddleware(
event: H3Event,
finalLocale: string,
langCodes: string[],
intro: string
): void {
// 이미 응답이 종료되었는지 확인
if (event.node.res.headersSent || event.node.res.writableEnded) {
return
}
const path = event?.node.req.url || ''
const finalPath = parseUrl(
path,
finalLocale,
langCodes,
ISO_LANGUAGE_CODES,
intro
)
// finalPath가 유효하지 않으면 리다이렉트하지 않음
if (!finalPath || typeof finalPath !== 'string') {
return
}
// 현재 경로와 최종 경로가 같으면 리다이렉트 불필요
if (path === finalPath) {
return
}
event.node.res.statusCode = 302
event.node.res.setHeader('Location', finalPath)
event.node.res.end()
}
/**
* 특정 경로를 건너뛰어야 하는지 확인
*/
function shouldSkipPath(path: string): boolean {
return (
path.startsWith('/_nuxt/') ||
path.startsWith('/__nuxt') ||
path.startsWith('/api/') ||
path.startsWith('/_i18n/') ||
path.includes('/assets/') ||
path.includes('favicon') ||
path === '/robots.txt' ||
path === '/sitemap.xml'
)
}
export default defineEventHandler(async event => {
const runtimeConfig = useRuntimeConfig()
// HMR 요청 필터링 (개발 모드에서 중복 실행 방지)
if (shouldSkipPath(event.path)) {
return
}
// 이미 응답이 종료되었는지 확인 (리다이렉트 등으로 인한 중복 실행 방지)
if (event.node.res.headersSent || event.node.res.writableEnded) {
return
}
const stoveApiServerBaseUrl = runtimeConfig.public.stoveApiUrlServer
let gameDataResponse: GameDataResponse | null = null
let gameDataLangCodes: string[] | null = null
let gameDataDefaultLocale: string | null = null
let gameDataIntro: string | null = null
let finalLocale: string
try {
const gameApiUrl = `${stoveApiServerBaseUrl}/pub-comm/v1.0/template/game`
const gameDomain = getGameDomain(event)
const queryParams: Record<string, string> = {
game_domain: gameDomain || '',
lang_code: getPathLocale(event?.node.req.url),
}
const response = await $fetch<GameDataResponse>(gameApiUrl, {
query: queryParams,
})
gameDataResponse = response
gameDataLangCodes = response?.value?.lang_codes || null
gameDataDefaultLocale = response?.value?.default_lang_code || null
gameDataIntro = response?.value?.intro?.page_url || ''
event.context.gameDomain = gameDomain
} catch (error) {
// eslint-disable-next-line no-console
console.error('[API Error] gameData load error:', error)
}
if (gameDataResponse?.code === 0 && 'value' in gameDataResponse) {
// ### 정상 응답 처리 -------------------------------------------------------------
const gameDataValue = gameDataResponse.value
event.context.gameData = gameDataValue
event.context.googleAnalyticsId = gameDataValue?.ga_code
console.log('🚀 ~ gameData response:', event.context.gameData)
// -------------------------------------------------------------------------------
// [Inspection Middleware]
// -------------------------------------------------------------------------------
const fullPath = event.path
// 1-1. 정적 파일 패스
if (isStaticFile(event.path)) return
// 1-2. /inspection 패스
if (fullPath.includes('/inspection')) return
// 1-3. 특정 경로 패스 (API, 리소스)
if (shouldSkipPath(fullPath)) return
// 캐시 키 생성 (게임 ID 포함하여 충돌 방지)
const gameId = gameDataValue?.game_id || 'default'
const cacheKey = `inspection:${gameId}`
try {
// 2. 언어 코드 추출
finalLocale = ssrGetFinalLocale(
event?.node.req.url,
event.node.req.headers,
gameDataLangCodes,
gameDataDefaultLocale
)
// 초기화
let inspectionData
// 3. 캐시된 데이터가 없거나 만료되었을 때만 API 호출
const cachedData = cache.get(cacheKey)
if (cachedData) {
inspectionData = cachedData
} else {
const inspectionApiUrl = `${stoveApiServerBaseUrl}/pub-comm/v3.0/inspection/${gameDataValue?.game_id}`
const response = await $fetch<ResGetInspectionData>(inspectionApiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
inspectionData = response?.value?.inspection as WebInspectionData
if (inspectionData) {
cache.set(cacheKey, inspectionData) // 캐시에 저장
}
}
// 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 as any)
// 허용된 IP 목록 확인
const allowedIPs = inspectionData?.ip_filter_list || []
if (!clientIP || !allowedIPs.includes(clientIP)) {
// 허용되지 않은 IP인 경우 점검 페이지로 이동
event.node.res.statusCode = 302
event.node.res.setHeader('Location', inspectionPath)
event.node.res.end()
} else {
// 화이트 리스트인 경우
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(
event,
finalLocale,
gameDataLangCodes,
gameDataIntro
)
}
} else {
if (!event.node.res.headersSent && !event.node.res.writableEnded) {
event.node.res.statusCode = 302
event.node.res.setHeader('Location', inspectionPath)
event.node.res.end()
}
}
} else {
/**
* 점검이 아닌 경우
* - 홈 경로는 no-cache
* - 점검 예정 시간에 따른 캐시 설정
* - 점검 5분 전: 짧은 캐시 (10초)
* - 점검 30분 전: 중간 캐시 (15초)
* - 점검 30분 이후: 기본 캐시 (60초)
*/
// 홈 경로: 캐시 없음
const isHomePath = fullPath === '' || 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, gameDataLangCodes, gameDataIntro)
}
// 정상 접속 허용
} catch (error) {
console.error('gameData inspection error:', error)
}
} else {
// ### 에러 응답 처리 -------------------------------------------------------------
// API 에러 코드를 명확하게 로깅하여 타입 에러와 구분
const apiErrorCode = gameDataResponse?.code
const apiErrorMessage = gameDataResponse?.message
// 언어 코드 추출 시도
let errorLocale = 'ko' // 기본값
try {
errorLocale = ssrGetFinalLocale(
event?.node.req.url,
event.node.req.headers,
gameDataLangCodes,
gameDataDefaultLocale
)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Locale extraction error:', e)
}
// 91001 에러인 경우 바로 리다이렉트
if (apiErrorCode === 91001) {
const errorPath = `/${errorLocale}/error`
event.node.res.statusCode = 302
event.node.res.setHeader('Location', errorPath)
event.node.res.end()
return
}
// 다른 에러는 기존대로 throw
throw createError({
statusCode: apiErrorCode || 500,
statusMessage: apiErrorMessage,
})
}
})