From 60d8cc0a107922c97ab2d0e66e5ef7c4b13d53ad Mon Sep 17 00:00:00 2001 From: clkim Date: Wed, 4 Feb 2026 10:15:59 +0900 Subject: [PATCH] =?UTF-8?q?fix.=20ISO=5FLANGUAGE=5FCODES=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- i18n.config.ts | 36 +++++++- layers/middleware/init.route.global.ts | 78 +++++++++-------- layers/middleware/pageData.global.ts | 8 +- layers/server/middleware/gameData.ts | 105 +++++++++-------------- layers/utils/urlUtil.ts | 111 ++++++++++++++++--------- 5 files changed, 191 insertions(+), 147 deletions(-) diff --git a/i18n.config.ts b/i18n.config.ts index 7f1cf93..d0f0896 100644 --- a/i18n.config.ts +++ b/i18n.config.ts @@ -11,6 +11,40 @@ const DEFAULT_LOCALES: Record = { 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 } diff --git a/layers/middleware/init.route.global.ts b/layers/middleware/init.route.global.ts index d79e98e..efdfc69 100644 --- a/layers/middleware/init.route.global.ts +++ b/layers/middleware/init.route.global.ts @@ -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 }) }) diff --git a/layers/middleware/pageData.global.ts b/layers/middleware/pageData.global.ts index 3fac53a..f57e710 100644 --- a/layers/middleware/pageData.global.ts +++ b/layers/middleware/pageData.global.ts @@ -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 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, diff --git a/layers/server/middleware/gameData.ts b/layers/server/middleware/gameData.ts index cf3e01e..9b3611f 100644 --- a/layers/server/middleware/gameData.ts +++ b/layers/server/middleware/gameData.ts @@ -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({ /** * 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) } // 정상 접속 허용 diff --git a/layers/utils/urlUtil.ts b/layers/utils/urlUtil.ts index b9c379d..4143dca 100644 --- a/layers/utils/urlUtil.ts +++ b/layers/utils/urlUtil.ts @@ -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) }