fix. ISO_LANGUAGE_CODES 코드 적용

This commit is contained in:
clkim
2026-02-04 10:15:59 +09:00
parent 741be80866
commit 60d8cc0a10
5 changed files with 191 additions and 147 deletions

View File

@@ -11,6 +11,40 @@ const DEFAULT_LOCALES: Record<string, LocaleObject> = {
th: { code: 'th', name: 'ภาษาไทย', iso: 'th', dir: 'ltr' },
}
const DEFAULT_LOCALE_CODE = 'ko'
const ISO_LANGUAGE_CODES = [
'en', // 영어
'zh', // 중국어(표준어/만다린)
'zh-tw', // 중국어(번체)
'zh-cn', // 중국어(간체)
'es', // 스페인어
'ar', // 아랍어
'id', // 인도네시아어
'pt', // 포르투갈어
'fr', // 프랑스어
'ja', // 일본어
'ru', // 러시아어
'de', // 독일어
'hi', // 힌디어
'vi', // 베트남어
'tr', // 터키어
'ko', // 한국어
'it', // 이탈리아어
'bn', // 벵골어
'pl', // 폴란드어
'th', // 태국어
'fa', // 페르시아어
'nl', // 네덜란드어
'ur', // 우르두어
'te', // 텔루구어
'mr', // 마라티어
'ta', // 타밀어
'tl', // 타갈로그어(필리핀어)
'uk', // 우크라이나어
'sw', // 스와힐리어
'ro', // 루마니아어
'hu', // 헝가리어
'ms', // 말레이어
]
// getI18n 함수가 NuxtI18nOptions 타입의 값을 반환하도록 명시적으로 타입을 지정합니다.
const getI18n = (allowedLangCodes?: string[]): NuxtI18nOptions => {
@@ -52,4 +86,4 @@ const getI18n = (allowedLangCodes?: string[]): NuxtI18nOptions => {
}
}
export { DEFAULT_LOCALE_CODE, DEFAULT_COVERAGES, getI18n }
export { DEFAULT_LOCALE_CODE, ISO_LANGUAGE_CODES, getI18n }

View File

@@ -1,44 +1,50 @@
import { DEFAULT_LOCALE_CODE, ISO_LANGUAGE_CODES } from '@/i18n.config'
import { csrGetFinalLocale } from '#layers/utils/localeUtil'
import { parseUrl, isExternalUrl } from '#layers/utils/urlUtil'
export default defineNuxtRouteMiddleware(to => {
// server에서는 실행X -----
// 서버에서는 실행하지 않음 (서버 미들웨어에서 이미 처리)
if (import.meta.server) return
// error 페이지는 실행X -----
if (to.path.includes('/error')) return
// inspection 페이지는 실행X -----
if (to.path.includes('/inspection')) return
// error 페이지와 inspection 페이지는 미들웨어 실행 제외
if (to.path.includes('/error') || to.path.includes('/inspection')) {
return
}
const gameDataStore = useGameDataStore()
const { langCodes, intro } = storeToRefs(gameDataStore)
const { langCodes, defaultLangCode, intro } = storeToRefs(gameDataStore)
// app.vue에서 설정한 스토어 값이 없으면 대기
if (!langCodes.value || !intro.value) return
// -------------------------------------------------------------------------------
// [Home Redirect]
// -------------------------------------------------------------------------------
const gamePath = getPathAfterLanguage(to.path)
const langCode = csrGetFinalLocale(to.path, langCodes.value)
const isRootPath = gamePath === '' || gamePath === '/'
if (isRootPath) {
// intro.page_url이 있으면 해당 URL로 리다이렉트, 없으면 /home으로
const introPageUrl = intro.value?.page_url
const redirectPath = getIntroRedirectPath(
introPageUrl,
langCode,
to.fullPath
)
// 무한 리다이렉트 방지: 현재 경로와 리다이렉트할 URL 비교
const normalizedFinalUrl = redirectPath.split('?')[0] // 리다이렉트 경로 (쿼리스트링 제외)
const isExternal = isExternalUrl(redirectPath)
const isSamePath = !isExternal && to.path === normalizedFinalUrl
if (!isSamePath) {
// 다른 경로에서 접근한 경우에만 리다이렉트
return navigateTo(redirectPath, { external: isExternal })
}
// 게임 데이터 스토어가 초기화되지 않았으면 대기
if (!langCodes.value || !defaultLangCode.value || !intro.value) {
return
}
const fullPath = to.fullPath || to.path || ''
// 최종 로케일 결정
const finalLocale =
csrGetFinalLocale(fullPath, langCodes.value) ||
defaultLangCode.value ||
DEFAULT_LOCALE_CODE
// parseUrl을 사용하여 최종 경로 계산
const introPageUrl = intro.value?.page_url || ''
const finalPath = parseUrl(
fullPath,
finalLocale,
langCodes.value,
ISO_LANGUAGE_CODES,
introPageUrl
)
// 현재 경로와 최종 경로가 같으면 리다이렉트 불필요
if (fullPath.split('?')[0] === finalPath.split('?')[0]) {
return
}
// 외부 URL인지 확인
const isExternal = isExternalUrl(finalPath)
// 리다이렉트 수행
return navigateTo(finalPath, { external: isExternal })
})

View File

@@ -25,7 +25,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const accessToken = csrGetAccessToken()
const gameDomain = getGameDomain()
const gamePath = getPathAfterLanguage(to.path)
const pathWithoutLocale = getPathAfterLanguage(to.path)
const langCode = csrGetFinalLocale(to.path, langCodes.value) || 'ko'
let pageDataResponse: PageDataResponse | null = null
@@ -41,7 +41,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
let queryParams: Record<string, string>
let apiUrl: string
if (gamePath === '/preview') {
if (pathWithoutLocale === '/preview') {
// 미리보기 쿼리스트링에서 파라미터 값 추출
// preview?page_seq=1&page_ver=1&lang_code=ko
const queryString = to.fullPath.includes('?')
@@ -64,7 +64,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
queryParams = {
game_domain: gameDomain,
lang_code: langCode,
page_url: gamePath,
page_url: pathWithoutLocale,
_t: Date.now().toString(), // 캐시 무효화를 위한 타임스탬프
}
}
@@ -114,7 +114,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
// 91002 (Invalid LangCode): 미지원 언어로 접근
if (pageDataResponse?.code === 91002) {
// 이미 /home 경로에 있으면 무한 리다이렉트 방지
if (gamePath === '/home') {
if (pathWithoutLocale === '/home') {
return createError({
statusCode: 404,
statusMessage: pageDataResponse?.message,

View File

@@ -1,12 +1,7 @@
import { LRUCache } from 'lru-cache'
import { defineEventHandler, createError, type H3Event } from 'h3'
import {
getGameDomain,
getPathLocale,
getIntroRedirectPath,
isExternalUrl,
getPathAfterLanguage,
} from '#layers/utils/urlUtil'
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'
@@ -73,41 +68,41 @@ const cache = new LRUCache<string, WebInspectionData>({
/**
* Locale Middleware 역할 함수
*
* @param event - 이벤트 객체
* @param finalLocale - 최종 언어
* URL의 언어 코드를 최종 언어로 변경하거나 추가
*/
function fnLocaleMiddleware(event: H3Event, finalLocale: string) {
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 [pathPart, queryString = ''] = path.split('?')
const arrPath = pathPart.split('/')
const finalPath = parseUrl(
path,
finalLocale,
langCodes,
ISO_LANGUAGE_CODES,
intro
)
// 최종 언어 세팅된 경로 생성
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()
// 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()
}
/**
@@ -144,6 +139,7 @@ export default defineEventHandler(async event => {
let gameDataResponse: GameDataResponse | null = null
let gameDataLangCodes: string[] | null = null
let gameDataDefaultLocale: string | null = null
let gameDataIntro: string | null = null
let finalLocale: string
try {
@@ -160,6 +156,7 @@ export default defineEventHandler(async event => {
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
@@ -263,7 +260,12 @@ export default defineEventHandler(async event => {
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
fnLocaleMiddleware(
event,
finalLocale,
gameDataLangCodes,
gameDataIntro
)
}
} else {
if (!event.node.res.headersSent && !event.node.res.writableEnded) {
@@ -302,40 +304,7 @@ export default defineEventHandler(async event => {
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
// -------------------------------------------------------------------------------
// [Home Redirect]
// -------------------------------------------------------------------------------
if (!event.node.res.headersSent && !event.node.res.writableEnded) {
const gamePath = getPathAfterLanguage(fullPath)
const isRootPath = gamePath === '' || gamePath === '/'
if (isRootPath) {
// intro.page_url이 있으면 해당 URL로 리다이렉트, 없으면 /home으로
const introPageUrl = gameDataValue?.intro?.page_url
const currentFullUrl = event.node.req.url || fullPath
const redirectPath = getIntroRedirectPath(
introPageUrl,
finalLocale,
currentFullUrl
)
// 무한 리다이렉트 방지: 현재 경로와 리다이렉트할 URL 비교
const currentPathOnly = fullPath.split('?')[0] // 현재 경로 (쿼리스트링 제외)
const normalizedFinalUrl = redirectPath.split('?')[0] // 리다이렉트 경로 (쿼리스트링 제외)
const isExternal = isExternalUrl(redirectPath)
const isSamePath =
!isExternal && currentPathOnly === normalizedFinalUrl
if (!isSamePath) {
// 다른 경로에서 접근한 경우에만 리다이렉트
event.node.res.statusCode = 302
event.node.res.setHeader('Location', redirectPath)
event.node.res.end()
}
}
}
fnLocaleMiddleware(event, finalLocale, gameDataLangCodes, gameDataIntro)
}
// 정상 접속 허용

View File

@@ -9,6 +9,13 @@ export const isExternalUrl = (url: string): boolean => {
return url.startsWith('http://') || url.startsWith('https://')
}
/**
* 경로에 쿼리스트링을 추가하는 함수
*/
export const addQueryString = (path: string, queryString: string): string => {
return queryString ? `${path}?${queryString}` : path
}
/**
* 게임 도메인을 추출하는 함수
* 서버와 클라이언트 환경에서 모두 동작
@@ -95,55 +102,83 @@ export const getPathAfterLanguage = (url: string): string => {
}
/**
* intro page_url을 기반으로 리다이렉트 경로를 생성하는 공통 함수
* @param introPageUrl - intro.page_url 값
* @param langCode - 현재 언어 코드
* @param currentUrl - 현재 접근한 URL (쿼리스트링 포함, 옵션)
* @returns 최종 리다이렉트 경로 (없으면 기본값 /home)
* URL을 파싱하여 최종 경로를 반환하는 함수
* 경로가 '/' 또는 ''이고 인트로 URL이 있으면 인트로로 리다이렉트,
* 그렇지 않으면 언어 코드를 추가한 URL을 반환
* SSR과 CSR 모두에서 작동
* @param url - URL 문자열
* @param finalLocale - 최종 언어 코드
* @param langCodes - 지원하는 언어 코드 배열
* @param ISO_LANGUAGE_CODES - ISO 언어 코드 배열
* @param introPageUrl - 인트로 페이지 URL (선택)
* @returns 최종 URL 문자열
*/
export const getIntroRedirectPath = (
introPageUrl: string | undefined | null,
langCode: string,
currentUrl?: string
export const parseUrl = (
url: string,
finalLocale: string,
langCodes: string[],
ISO_LANGUAGE_CODES: string[],
introPageUrl?: string
): string => {
// 기본값: /langCode/home
let defaultPath = `/${langCode}/home`
const [pathPart, queryString = ''] = url.split('?')
const pathSegments = pathPart.split('/').filter(Boolean)
const currentLocale = pathSegments[0]
const isKnownLocale =
langCodes.includes(currentLocale) ||
ISO_LANGUAGE_CODES.includes(currentLocale)
const isEmptyPath =
pathSegments.length === 0 || (pathSegments.length === 1 && isKnownLocale)
if (introPageUrl && introPageUrl.trim() !== '') {
if (isExternalUrl(introPageUrl)) {
// 외부 URL인 경우 그대로 사용
defaultPath = introPageUrl
} else {
// 내부 경로인 경우 언어 코드 패턴 확인
const normalizedIntroUrl = introPageUrl.split('?')[0] // 쿼리스트링 제외
const languagePattern = /^\/[a-z]{2}(-[a-z]{2})?(\/|$)/
const hasLanguageCode = languagePattern.test(normalizedIntroUrl)
// 경로가 '/' 또는 ''인 경우 인트로 URL 처리
if (isEmptyPath) {
const hasIntroUrl = introPageUrl?.trim()
if (hasIntroUrl) {
// 외부 URL인 경우 그대로 반환
if (isExternalUrl(introPageUrl)) {
return introPageUrl
}
// 내부 경로인 경우 언어 코드 추가
// 인트로 URL이 이미 언어 코드로 시작하는지 확인
const introPathSegments = introPageUrl.split('/').filter(Boolean)
const introFirstSegment = introPathSegments[0]
const introHasLocale =
introFirstSegment &&
(langCodes.includes(introFirstSegment) ||
ISO_LANGUAGE_CODES.includes(introFirstSegment))
if (hasLanguageCode) {
// 이미 언어 코드가 있으면 그대로 사용
defaultPath = introPageUrl
let introPath: string
if (introHasLocale) {
// 이미 언어 코드가 있으면 언어 코드만 교체
introPath = '/' + [finalLocale, ...introPathSegments.slice(1)].join('/')
} else {
// 언어 코드가 없으면 추가
const pathWithSlash = normalizedIntroUrl.startsWith('/')
? normalizedIntroUrl
: `/${normalizedIntroUrl}`
defaultPath = `/${langCode}${pathWithSlash}`
// 쿼리스트링이 있으면 다시 추가
if (introPageUrl.includes('?')) {
defaultPath += '?' + introPageUrl.split('?')[1]
}
introPath = `/${finalLocale}${introPageUrl}`
}
return addQueryString(introPath, queryString)
}
// 인트로 URL이 없으면 기본 홈 경로 반환
return addQueryString(`/${finalLocale}/home`, queryString)
}
// intro에 쿼리스트링이 없고, 현재 URL에 쿼리스트링이 있으면 추가
if (!defaultPath.includes('?') && currentUrl && currentUrl.includes('?')) {
const queryString = currentUrl.split('?')[1]
if (queryString) {
defaultPath += '?' + queryString
// 리다이렉트 경로 생성
const remainingPath = pathSegments.slice(1)
const hasRemainingPath = remainingPath.length > 0
let newPath: string
if (isKnownLocale) {
// 현재 언어 코드와 최종 언어가 같으면 리다이렉트 불필요
if (currentLocale === finalLocale) return url
// 유효한 언어 코드가 있지만 다른 언어인 경우
if (hasRemainingPath) {
// 경로가 있으면 언어 코드만 교체 (/vi/story -> /finalLocale/story)
newPath = '/' + [finalLocale, ...remainingPath].join('/')
}
} else {
// 언어 코드가 없거나 유효하지 않은 경우: 언어 코드를 앞에 추가
const pathWithoutSlash = pathPart === '/' ? '' : pathPart
newPath = `/${finalLocale}${pathWithoutSlash}`
}
return defaultPath
return addQueryString(newPath, queryString)
}