/** * Meilisearch 검색 서비스 * - 일정 검색 (멤버 별명 → 이름 변환) * - 영문 자판 → 한글 변환 * - 유사도 필터링 * - 일정 동기화 */ import Inko from 'inko'; import config, { CATEGORY_IDS } from '../../config/index.js'; import { createLogger } from '../../utils/logger.js'; const inko = new Inko(); const logger = createLogger('Meilisearch'); const INDEX_NAME = 'schedules'; const MIN_SCORE = config.meilisearch.minScore; /** * 영문 자판으로 입력된 검색어인지 확인 */ function isEnglishKeyboard(text) { const englishChars = text.match(/[a-zA-Z]/g) || []; const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []; return englishChars.length > 0 && koreanChars.length === 0; } /** * 별명/이름으로 멤버 이름 조회 */ export async function resolveMemberNames(db, query) { const searchTerm = `%${query}%`; const [members] = await db.query(` SELECT DISTINCT m.name FROM members m LEFT JOIN member_nicknames mn ON m.id = mn.member_id WHERE m.name LIKE ? OR mn.nickname LIKE ? `, [searchTerm, searchTerm]); return members.map(m => m.name); } /** * 일정 검색 * @param {object} meilisearch - Meilisearch 클라이언트 * @param {object} db - DB 연결 풀 * @param {string} query - 검색어 * @param {object} options - 검색 옵션 */ export async function searchSchedules(meilisearch, db, query, options = {}) { const { limit = 1000, offset = 0 } = options; try { const index = meilisearch.index(INDEX_NAME); const searchOptions = { limit, offset: 0, // 내부적으로 전체 검색 후 필터링 attributesToRetrieve: ['*'], showRankingScore: true, }; // 검색어 목록 구성 const searchQueries = [query]; // 영문 자판 입력 → 한글 변환 if (isEnglishKeyboard(query)) { const koreanQuery = inko.en2ko(query); if (koreanQuery !== query) { searchQueries.push(koreanQuery); } } // 별명 → 멤버 이름 변환 const memberNames = await resolveMemberNames(db, query); for (const name of memberNames) { if (!searchQueries.includes(name)) { searchQueries.push(name); } } // 각 검색어로 검색 후 병합 const allHits = new Map(); // id 기준 중복 제거 for (const q of searchQueries) { const results = await index.search(q, searchOptions); for (const hit of results.hits) { // 더 높은 점수로 업데이트 if (!allHits.has(hit.id) || allHits.get(hit.id)._rankingScore < hit._rankingScore) { allHits.set(hit.id, hit); } } } // 유사도 필터링 let filteredHits = Array.from(allHits.values()) .filter(hit => hit._rankingScore >= MIN_SCORE); // 유사도 순 정렬 filteredHits.sort((a, b) => (b._rankingScore || 0) - (a._rankingScore || 0)); const total = filteredHits.length; // 페이징 적용 const paginatedHits = filteredHits.slice(offset, offset + limit); // 응답 형식 변환 const formattedHits = paginatedHits.map(formatScheduleResponse); return { hits: formattedHits, total, offset, limit, hasMore: offset + paginatedHits.length < total, }; } catch (err) { logger.error(`검색 오류: ${err.message}`); return { hits: [], total: 0, offset: 0, limit, hasMore: false }; } } /** * 검색 결과 응답 형식 변환 */ function formatScheduleResponse(hit) { // date + time 합치기 let datetime = null; if (hit.date) { const dateStr = hit.date instanceof Date ? hit.date.toISOString().split('T')[0] : String(hit.date).split('T')[0]; if (hit.time) { datetime = `${dateStr}T${hit.time}`; } else { datetime = dateStr; } } // member_names를 배열로 변환 const members = hit.member_names ? hit.member_names.split(',').map(name => name.trim()).filter(Boolean) : []; // source 객체 구성 (X는 name 비움) let source = null; if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) { source = { name: hit.source_name, url: null }; } else if (hit.category_id === CATEGORY_IDS.X) { source = { name: '', url: null }; } return { id: hit.id, title: hit.title, datetime, category: { id: hit.category_id, name: hit.category_name, color: hit.category_color, }, source, members, _rankingScore: hit._rankingScore, }; } /** * 일정 추가/업데이트 */ export async function addOrUpdateSchedule(meilisearch, schedule) { try { const index = meilisearch.index(INDEX_NAME); const document = { id: schedule.id, title: schedule.title, description: schedule.description || '', date: schedule.date, time: schedule.time || '', category_id: schedule.category_id, category_name: schedule.category_name || '', category_color: schedule.category_color || '', source_name: schedule.source_name || '', member_names: schedule.member_names || '', }; await index.addDocuments([document]); logger.info(`일정 추가/업데이트: ${schedule.id}`); } catch (err) { logger.error(`문서 추가 오류: ${err.message}`); } } /** * 일정 삭제 */ export async function deleteSchedule(meilisearch, scheduleId) { try { const index = meilisearch.index(INDEX_NAME); await index.deleteDocument(scheduleId); logger.info(`일정 삭제: ${scheduleId}`); } catch (err) { logger.error(`문서 삭제 오류: ${err.message}`); } } /** * 전체 일정 동기화 */ export async function syncAllSchedules(meilisearch, db) { try { // DB에서 모든 일정 조회 const [schedules] = await db.query(` SELECT s.id, s.title, s.description, s.date, s.time, s.category_id, c.name as category_name, c.color as category_color, sy.channel_name as source_name, GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id LEFT JOIN schedule_members sm ON s.id = sm.schedule_id LEFT JOIN members m ON sm.member_id = m.id GROUP BY s.id `); const index = meilisearch.index(INDEX_NAME); // 기존 문서 모두 삭제 await index.deleteAllDocuments(); // 문서 변환 const documents = schedules.map(s => ({ id: s.id, title: s.title, description: s.description || '', date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date, time: s.time || '', category_id: s.category_id, category_name: s.category_name || '', category_color: s.category_color || '', source_name: s.source_name || '', member_names: s.member_names || '', })); // 일괄 추가 await index.addDocuments(documents); logger.info(`${documents.length}개 일정 동기화 완료`); return documents.length; } catch (err) { logger.error(`동기화 오류: ${err.message}`); return 0; } }