/** * 한국어 스마트 검색 유틸리티 * - 초성 추출 및 매칭 * - 별칭(alias) 매칭 * - 인메모리 캐시 (5분 TTL) */ const { getDb } = require('../dbPool'); // 초성 목록 (19개) const CHOSUNG = [ 'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ', 'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ' ]; // 자음 문자 집합 (초성 판별용) const JAMO_SET = new Set([ 'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ', 'ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ' ]); // 캐시 let cache = null; let cacheTime = 0; const CACHE_TTL = 5 * 60 * 1000; // 5분 /** * 한글 완성형 문자에서 초성 추출 * @param {string} str * @returns {string} 초성 문자열 */ function extractChosung(str) { let result = ''; for (const ch of str) { const code = ch.charCodeAt(0); if (code >= 0xAC00 && code <= 0xD7A3) { const idx = Math.floor((code - 0xAC00) / (21 * 28)); result += CHOSUNG[idx]; } else { result += ch; } } return result; } /** * 검색어가 모두 자음(초성)인지 판별 * @param {string} query * @returns {boolean} */ function isChosungOnly(query) { if (query.length < 2) return false; for (const ch of query) { if (!JAMO_SET.has(ch)) return false; } return true; } /** * 캐시 로드 (consumable_items + item_aliases) */ async function loadCache() { if (cache && (Date.now() - cacheTime < CACHE_TTL)) return cache; const db = await getDb(); const [items] = await db.query( `SELECT item_id, item_name, spec, maker, category, base_price, unit, photo_path FROM consumable_items WHERE is_active = 1` ); const [aliases] = await db.query( `SELECT alias_id, item_id, alias_name FROM item_aliases` ); // 아이템별 별칭 맵 생성 const aliasMap = {}; for (const a of aliases) { if (!aliasMap[a.item_id]) aliasMap[a.item_id] = []; aliasMap[a.item_id].push(a.alias_name); } // 초성 미리 계산 const enriched = items.map(item => ({ ...item, aliases: aliasMap[item.item_id] || [], chosung_name: extractChosung(item.item_name), chosung_aliases: (aliasMap[item.item_id] || []).map(a => extractChosung(a)) })); cache = enriched; cacheTime = Date.now(); return cache; } /** * 캐시 무효화 */ function clearCache() { cache = null; cacheTime = 0; } /** * 스마트 검색 * @param {string} query - 검색어 * @returns {Promise} 스코어 기준 상위 20건 */ async function search(query) { if (!query || query.trim().length === 0) return []; const items = await loadCache(); const q = query.trim().toLowerCase(); const qChosung = isChosungOnly(q) ? q : null; const scored = []; for (const item of items) { let score = 0; let matchType = ''; const nameLower = item.item_name.toLowerCase(); const specLower = (item.spec || '').toLowerCase(); const makerLower = (item.maker || '').toLowerCase(); // exact match (이름 완전 일치) if (nameLower === q) { score = 100; matchType = 'exact'; } // substring match (이름) else if (nameLower.includes(q)) { score = 80; matchType = 'name'; } // alias match else if (item.aliases.some(a => a.toLowerCase().includes(q))) { score = 75; matchType = 'alias'; } // spec/maker match else if (specLower.includes(q) || makerLower.includes(q)) { score = 70; matchType = 'spec'; } // 초성 매칭 (이름) else if (qChosung && item.chosung_name.includes(qChosung)) { score = 50; matchType = 'chosung'; } // 초성 매칭 (별칭) else if (qChosung && item.chosung_aliases.some(ca => ca.includes(qChosung))) { score = 40; matchType = 'chosung_alias'; } if (score > 0) { scored.push({ ...item, _score: score, _matchType: matchType }); } } // 점수 높은 순, 같은 점수면 이름 짧은 순 (더 구체적) scored.sort((a, b) => b._score - a._score || a.item_name.length - b.item_name.length); return scored.slice(0, 20); } module.exports = { search, clearCache, extractChosung, isChosungOnly };