511 lines
16 KiB
TypeScript
511 lines
16 KiB
TypeScript
import { LRUCache } from 'lru-cache'
|
|
import {
|
|
getHeader,
|
|
getRequestHost,
|
|
defineEventHandler,
|
|
createError,
|
|
setCookie,
|
|
type H3Event,
|
|
} from 'h3'
|
|
import { ssrGetFinalLocale } from '../../utils/localeUtil'
|
|
import type { GameDataResponse } from '../../types/api/gameData'
|
|
import type {
|
|
ResGetInspectionData,
|
|
WebInspectionData,
|
|
} from '../../types/InspectionType'
|
|
import { isStaticFile } from '#layers/utils/commonUtil'
|
|
import { getTrueClientIp } from '#layers/utils/apiUtil'
|
|
|
|
/**
|
|
* 캐시 제어 헤더를 설정하는 공통 함수
|
|
*
|
|
* @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({
|
|
max: 100, // 캐시에 저장할 최대 항목 수
|
|
ttl: 1000 * 30, // 30초 동안 캐시 유지
|
|
})
|
|
|
|
/**
|
|
* 최종 언어 쿠키 세팅
|
|
*
|
|
* @param event - 이벤트 객체
|
|
* @param finalLocale - 최종 언어
|
|
* @param baseDomain - 기본 도메인
|
|
*/
|
|
function setFinalLocaleCookie(
|
|
event: H3Event,
|
|
finalLocale: string,
|
|
baseDomain: string
|
|
) {
|
|
setCookie(event, 'LOCALE', finalLocale.toLowerCase(), {
|
|
domain: baseDomain,
|
|
path: '/',
|
|
maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Locale Middleware 역할 함수
|
|
*
|
|
* @param event - 이벤트 객체
|
|
* @param finalLocale - 최종 언어
|
|
*/
|
|
function fnLocaleMiddleware(event: H3Event, finalLocale: string) {
|
|
// 이미 응답이 종료되었는지 확인
|
|
if (event.node.res.headersSent || event.node.res.writableEnded) {
|
|
return
|
|
}
|
|
|
|
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 => {
|
|
// HMR 요청 필터링 (개발 모드에서 중복 실행 방지)
|
|
if (event.path.startsWith('/_nuxt/') || event.path.startsWith('/__nuxt')) {
|
|
return
|
|
}
|
|
|
|
if (event.path.includes('/error')) {
|
|
return
|
|
}
|
|
|
|
// 이미 응답이 종료되었는지 확인 (리다이렉트 등으로 인한 중복 실행 방지)
|
|
if (event.node.res.headersSent || event.node.res.writableEnded) {
|
|
return
|
|
}
|
|
|
|
const runtimeConfig = useRuntimeConfig()
|
|
const iBaseApiUrl = runtimeConfig.public.stoveApiUrlServer
|
|
const baseDomain = runtimeConfig.public.baseDomain
|
|
const apiUrl = `${iBaseApiUrl}/pub-comm/v1.0/template/game`
|
|
|
|
let initLangCodes: string[] | null = null
|
|
let finalLocale: string
|
|
let cleanHost: string | undefined
|
|
let initDefaultLocale: string | null = null
|
|
|
|
const host =
|
|
(getHeader(event, 'host') || getRequestHost(event)).toString() || ''
|
|
const isGameDomainExtractable = host.includes(baseDomain)
|
|
|
|
if (isGameDomainExtractable) {
|
|
cleanHost = host.split(':')[0]
|
|
event.context.gameDomain = cleanHost
|
|
}
|
|
|
|
try {
|
|
const queryParams: Record<string, string> = {
|
|
game_domain: cleanHost || '',
|
|
lang_code: '',
|
|
}
|
|
const initResponse = await $fetch<GameDataResponse>(apiUrl, {
|
|
query: queryParams,
|
|
})
|
|
|
|
initLangCodes = initResponse?.value?.lang_codes || null
|
|
initDefaultLocale = initResponse?.value?.default_lang_code || null
|
|
} catch (error) {
|
|
console.error('init gameData load error:', error)
|
|
|
|
return
|
|
}
|
|
|
|
const fullPath = event.path
|
|
|
|
// 1-1. 정적 파일 패스
|
|
if (isStaticFile(event.path)) {
|
|
return
|
|
}
|
|
|
|
// 1-2. /inspection 패스
|
|
if (fullPath.includes('/inspection')) {
|
|
// 리턴 되기 전 언어 쿠키 세팅
|
|
finalLocale = ssrGetFinalLocale(
|
|
event?.node.req.url,
|
|
event.node.req.headers,
|
|
initLangCodes,
|
|
initDefaultLocale
|
|
)
|
|
setFinalLocaleCookie(event, finalLocale, baseDomain)
|
|
return
|
|
}
|
|
|
|
// 정적 자산, API, 파비콘 등은 제외하고 페이지 요청만 처리
|
|
if (
|
|
fullPath.startsWith('/api/') ||
|
|
fullPath.startsWith('/_nuxt/') ||
|
|
fullPath.startsWith('/favicon') ||
|
|
fullPath.includes('/assets/') ||
|
|
fullPath.includes('.') ||
|
|
fullPath.startsWith('/_')
|
|
) {
|
|
return
|
|
}
|
|
|
|
// 캐시 키 생성
|
|
const cacheKey = 'inspection'
|
|
|
|
try {
|
|
// 이미 응답이 종료되었는지 확인
|
|
if (event.node.res.headersSent || event.node.res.writableEnded) {
|
|
return
|
|
}
|
|
|
|
// 2. 언어 코드 추출
|
|
finalLocale = ssrGetFinalLocale(
|
|
event?.node.req.url,
|
|
event.node.req.headers,
|
|
initLangCodes,
|
|
initDefaultLocale
|
|
)
|
|
|
|
// 쿼리스트링에서 f 파라미터 값 추출
|
|
const path = event?.node.req.url || ''
|
|
let fValue = ''
|
|
if (path.includes('?')) {
|
|
try {
|
|
const queryString = path.split('?')[1]
|
|
const urlParams = new URLSearchParams(queryString)
|
|
fValue = urlParams.get('f') || ''
|
|
} catch (e) {
|
|
console.error('쿼리스트링 파싱 에러:', e)
|
|
}
|
|
}
|
|
|
|
// 미리보기 API 호출 처리
|
|
if (fValue === 'preview') {
|
|
cleanHost = 'samplegame.onstove.com'
|
|
}
|
|
|
|
const queryParams: Record<string, string> = {
|
|
game_domain: cleanHost || '',
|
|
lang_code: finalLocale,
|
|
}
|
|
|
|
const response = await $fetch<GameDataResponse>(apiUrl, {
|
|
query: queryParams,
|
|
})
|
|
|
|
console.log('🚀 ~ gameData response:', response)
|
|
|
|
// 언어패스 쿠키 굽기 - 장기방안에서는 굽지않음
|
|
if (initLangCodes?.includes(finalLocale)) {
|
|
setFinalLocaleCookie(event, finalLocale, baseDomain)
|
|
}
|
|
|
|
if (response?.code === 91001) {
|
|
// 91001 에러 발생 시 바로 /error 페이지로 리다이렉트
|
|
if (!event.node.res.headersSent && !event.node.res.writableEnded) {
|
|
const errorPath = `/${finalLocale || 'ko'}/error`
|
|
event.node.res.statusCode = 302
|
|
event.node.res.setHeader('Location', errorPath)
|
|
event.node.res.end()
|
|
return
|
|
}
|
|
}
|
|
|
|
if (response?.code === 0 && 'value' in response) {
|
|
event.context.gameData = response.value
|
|
event.context.googleAnalyticsId = response.value?.ga_code
|
|
|
|
// 점검 데이터 조회
|
|
let inspectionData: WebInspectionData | undefined
|
|
if (cache.has(cacheKey)) {
|
|
inspectionData = cache.get(cacheKey) as WebInspectionData
|
|
} else {
|
|
// 점검 데이터 조회
|
|
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
|
|
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 설정
|
|
* - 화이트 리스트 체크
|
|
*/
|
|
const inspectionPath = `/${finalLocale}/inspection`
|
|
const isInspectionPath = fullPath === inspectionPath
|
|
|
|
// 현재 경로가 점검 페이지가 아닐 경우 캐시 헤더 설정
|
|
if (!isInspectionPath) {
|
|
setCacheHeaders(event, 'no-cache')
|
|
}
|
|
|
|
// 응답이 이미 종료되었는지 확인
|
|
if (event.node.res.headersSent || event.node.res.writableEnded) {
|
|
return
|
|
}
|
|
|
|
// 점검 중일 때 IP 필터링 활성화 여부 확인
|
|
if (inspectionData?.ip_filter_use_yn === 'Y') {
|
|
const clientIP = getTrueClientIp(event.node.req as any)
|
|
|
|
// 허용된 IP 목록 확인
|
|
if (inspectionData?.ip_filter_list?.includes(clientIP)) {
|
|
// 화이트 리스트인 경우 Locale Middleware 실행
|
|
fnLocaleMiddleware(event, finalLocale)
|
|
} else {
|
|
// 허용되지 않은 IP인 경우 점검 페이지로 이동
|
|
event.node.res.statusCode = 302
|
|
event.node.res.setHeader('Location', inspectionPath)
|
|
event.node.res.end()
|
|
}
|
|
} else {
|
|
// IP 필터링이 비활성화된 경우 모든 사용자를 점검 페이지로 이동
|
|
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}/`])
|
|
console.log("🚀 ~ isHomePath: 여기야??", fullPath)
|
|
].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')
|
|
}
|
|
}
|
|
}
|
|
// -------------------------------------------------------------------------------
|
|
// [Root Path Redirect to /home or intro.page_url]
|
|
// 언어 코드만 있는 경로(예: /ko, /ko/)를 /home 또는 intro.page_url로 리다이렉트
|
|
// -------------------------------------------------------------------------------
|
|
const normalizedPath = fullPath.endsWith('/')
|
|
? fullPath.slice(0, -1)
|
|
: fullPath
|
|
const localePath = `/${finalLocale}`
|
|
if (normalizedPath === localePath) {
|
|
// intro.page_url이 있으면 해당 값 사용, 없으면 /home 사용
|
|
const introPageUrl = response.value?.intro?.page_url
|
|
let defaultPath = `/${finalLocale}/home`
|
|
|
|
if (introPageUrl && introPageUrl.trim() !== '') {
|
|
// 외부 URL인지 확인
|
|
const isExternalUrl = introPageUrl.startsWith('http://') || introPageUrl.startsWith('https://')
|
|
|
|
if (isExternalUrl) {
|
|
// 외부 URL인 경우 그대로 사용
|
|
defaultPath = introPageUrl
|
|
} else {
|
|
// 내부 경로인 경우 언어 코드 패턴 확인
|
|
const normalizedIntroUrl = introPageUrl.split('?')[0] // 쿼리스트링 제외
|
|
const languagePattern = /^\/[a-z]{2}(-[a-z]{2})?(\/|$)/
|
|
const hasLanguageCode = languagePattern.test(normalizedIntroUrl)
|
|
|
|
if (hasLanguageCode) {
|
|
// 이미 언어 코드가 있으면 그대로 사용
|
|
defaultPath = introPageUrl
|
|
} else {
|
|
// 언어 코드가 없으면 추가
|
|
const pathWithSlash = normalizedIntroUrl.startsWith('/')
|
|
? normalizedIntroUrl
|
|
: `/${normalizedIntroUrl}`
|
|
defaultPath = `/${finalLocale}${pathWithSlash}`
|
|
|
|
// 쿼리스트링이 있으면 다시 추가
|
|
if (introPageUrl.includes('?')) {
|
|
defaultPath += '?' + introPageUrl.split('?')[1]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const queryString = event?.node.req.url?.includes('?')
|
|
? '?' + event.node.req.url.split('?')[1]
|
|
: ''
|
|
|
|
if (!event.node.res.headersSent && !event.node.res.writableEnded) {
|
|
// 쿼리스트링 처리
|
|
let redirectUrl = defaultPath
|
|
if (queryString) {
|
|
// defaultPath에 이미 쿼리스트링이 있는지 확인
|
|
if (defaultPath.includes('?')) {
|
|
redirectUrl = `${defaultPath}&${queryString.substring(1)}`
|
|
} else {
|
|
redirectUrl = `${defaultPath}${queryString}`
|
|
}
|
|
}
|
|
|
|
event.node.res.statusCode = 302
|
|
event.node.res.setHeader('Location', redirectUrl)
|
|
event.node.res.end()
|
|
return
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------
|
|
// [Locale Middleware]
|
|
// -------------------------------------------------------------------------------
|
|
fnLocaleMiddleware(event, finalLocale)
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
console.error('gameData load error:', error)
|
|
|
|
// 응답이 이미 종료되었는지 확인
|
|
if (event.node.res.headersSent || event.node.res.writableEnded) {
|
|
return
|
|
}
|
|
|
|
// 언어 코드 추출 시도
|
|
let errorLocale = 'ko' // 기본값
|
|
try {
|
|
errorLocale = ssrGetFinalLocale(
|
|
event?.node.req.url,
|
|
event.node.req.headers,
|
|
initLangCodes,
|
|
initDefaultLocale
|
|
)
|
|
} catch (e) {
|
|
console.error('Locale extraction error:', e)
|
|
}
|
|
|
|
// 91001 에러인 경우 바로 리다이렉트
|
|
if (error?.statusCode === 91001 || error?.cause?.statusCode === 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: error?.statusCode || 500,
|
|
statusMessage: error?.statusMessage,
|
|
})
|
|
}
|
|
})
|