import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; import prisma from "../config/database"; // 메모리 캐시 (개발 환경용, 운영에서는 Redis 사용 권장) const translationCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5분 interface CacheEntry { data: any; timestamp: number; } /** * GET /api/multilang/batch * 다국어 텍스트 배치 조회 API - 여러 키를 한번에 조회 */ export const getBatchTranslations = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { const { companyCode, menuCode, userLang } = req.query; const { langKeys } = req.body; // 배열로 여러 키 전달 logger.info("다국어 텍스트 배치 조회 요청", { companyCode, menuCode, userLang, keyCount: langKeys?.length || 0, user: req.user, }); if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { res.status(400).json({ success: false, message: "langKeys 배열이 필요합니다.", }); return; } // 캐시 키 생성 const cacheKey = `${companyCode}_${userLang}_${langKeys.sort().join("_")}`; // 캐시 확인 const cached = translationCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { logger.info("캐시된 번역 데이터 사용", { cacheKey, keyCount: langKeys.length, }); res.status(200).json({ success: true, data: cached.data, message: "캐시된 다국어 텍스트 조회 성공", fromCache: true, }); return; } // 1. 모든 키에 대한 마스터 정보를 한번에 조회 logger.info("다국어 키 마스터 배치 조회 시작", { keyCount: langKeys.length, }); const langKeyMasters = await prisma.$queryRaw` SELECT key_id, lang_key, company_code FROM multi_lang_key_master WHERE lang_key = ANY(${langKeys}::varchar[]) AND (company_code = ${companyCode}::varchar OR company_code = '*') ORDER BY CASE WHEN company_code = ${companyCode}::varchar THEN 1 ELSE 2 END, lang_key, company_code `; logger.info("다국어 키 마스터 배치 조회 결과", { requestedKeys: langKeys.length, foundKeys: langKeyMasters.length, }); if (langKeyMasters.length === 0) { // 마스터 데이터가 없으면 기본값 반환 const defaultTranslations = getDefaultTranslations( langKeys, userLang as string ); // 캐시에 저장 translationCache.set(cacheKey, { data: defaultTranslations, timestamp: Date.now(), }); res.status(200).json({ success: true, data: defaultTranslations, message: "기본값으로 다국어 텍스트 조회 성공", fromCache: false, }); return; } // 2. 모든 key_id를 추출 const keyIds = langKeyMasters.map((master: any) => master.key_id); // 3. 요청된 언어와 한국어 번역을 한번에 조회 const translations = await prisma.$queryRaw` SELECT mlt.key_id, mlt.lang_code, mlt.lang_text, mlkm.lang_key FROM multi_lang_text mlt JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id WHERE mlt.key_id = ANY(${keyIds}::numeric[]) AND mlt.lang_code IN (${userLang}::varchar, 'KR') ORDER BY mlt.key_id, CASE WHEN mlt.lang_code = ${userLang}::varchar THEN 1 ELSE 2 END `; logger.info("번역 텍스트 배치 조회 결과", { keyIds: keyIds.length, translations: translations.length, }); // 4. 결과를 키별로 정리 const result: Record = {}; for (const langKey of langKeys) { const master = langKeyMasters.find((m) => m.lang_key === langKey); if (master) { const keyId = master.key_id; // 요청된 언어 번역 찾기 let translation = translations.find( (t) => t.key_id === keyId && t.lang_code === userLang ); // 요청된 언어가 없으면 한국어 번역 찾기 if (!translation) { translation = translations.find( (t) => t.key_id === keyId && t.lang_code === "KR" ); } // 번역이 있으면 사용, 없으면 기본값 if (translation) { result[langKey] = translation.lang_text; } else { result[langKey] = getDefaultTranslation(langKey, userLang as string); } } else { // 마스터 데이터가 없으면 기본값 result[langKey] = getDefaultTranslation(langKey, userLang as string); } } // 5. 캐시에 저장 translationCache.set(cacheKey, { data: result, timestamp: Date.now(), }); logger.info("다국어 텍스트 배치 조회 완료", { requestedKeys: langKeys.length, resultKeys: Object.keys(result).length, cacheKey, }); res.status(200).json({ success: true, data: result, message: "다국어 텍스트 배치 조회 성공", fromCache: false, }); } catch (error) { logger.error("다국어 텍스트 배치 조회 실패", { error }); res.status(500).json({ success: false, message: "다국어 텍스트 배치 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } }; /** * GET /api/multilang/user-text/:companyCode/:menuCode/:langKey * 단일 다국어 텍스트 조회 API (하위 호환성 유지) */ export const getUserText = async (req: AuthenticatedRequest, res: Response) => { try { const { companyCode, menuCode, langKey } = req.params; const { userLang } = req.query; logger.info("단일 다국어 텍스트 조회 요청", { companyCode, menuCode, langKey, userLang, user: req.user, }); // 배치 API를 사용하여 단일 키 조회 const batchResult = await getBatchTranslations( { ...req, body: { langKeys: [langKey] }, query: { companyCode, menuCode, userLang }, } as any, res ); // 배치 API에서 이미 응답을 보냈으므로 여기서는 아무것도 하지 않음 return; } catch (error) { logger.error("단일 다국어 텍스트 조회 실패", { error }); res.status(500).json({ success: false, message: "다국어 텍스트 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } }; /** * 기본 번역 텍스트 반환 (개별 키) */ function getDefaultTranslation(langKey: string, userLang: string): string { const defaultKoreanTexts: Record = { "button.add": "추가", "button.add.top.level": "최상위 메뉴 추가", "button.add.sub": "하위 메뉴 추가", "button.edit": "수정", "button.delete": "삭제", "button.cancel": "취소", "button.save": "저장", "button.register": "등록", "form.menu.name": "메뉴명", "form.menu.url": "URL", "form.menu.description": "설명", "form.menu.type": "메뉴 타입", "form.status": "상태", "form.company": "회사", "table.header.menu.name": "메뉴명", "table.header.menu.url": "URL", "table.header.status": "상태", "table.header.company": "회사", "table.header.actions": "작업", "filter.company": "회사", "filter.search": "검색", "filter.reset": "초기화", "menu.type.title": "메뉴 타입", "menu.type.admin": "관리자", "menu.type.user": "사용자", "status.active": "활성화", "status.inactive": "비활성화", "form.lang.key": "언어 키", "form.lang.key.select": "언어 키 선택", "form.menu.name.placeholder": "메뉴명을 입력하세요", "form.menu.url.placeholder": "URL을 입력하세요", "form.menu.description.placeholder": "설명을 입력하세요", "form.menu.sequence": "순서", "form.menu.sequence.placeholder": "순서를 입력하세요", "form.status.active": "활성", "form.status.inactive": "비활성", "form.company.select": "회사 선택", "form.company.common": "공통", "form.company.submenu.note": "하위메뉴는 회사별로 관리됩니다", "filter.company.common": "공통", "filter.search.placeholder": "검색어를 입력하세요", "modal.menu.register.title": "메뉴 등록", }; return defaultKoreanTexts[langKey] || langKey; } /** * 기본 번역 텍스트 반환 (배치) */ function getDefaultTranslations( langKeys: string[], userLang: string ): Record { const result: Record = {}; for (const langKey of langKeys) { result[langKey] = getDefaultTranslation(langKey, userLang); } return result; } /** * 캐시 초기화 (개발/테스트용) */ export const clearCache = async (req: AuthenticatedRequest, res: Response) => { try { const beforeSize = translationCache.size; translationCache.clear(); logger.info("다국어 캐시 초기화 완료", { beforeSize, afterSize: 0, user: req.user, }); res.status(200).json({ success: true, message: "캐시가 초기화되었습니다.", beforeSize, afterSize: 0, }); } catch (error) { logger.error("캐시 초기화 실패", { error }); res.status(500).json({ success: false, message: "캐시 초기화 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } };