/** * Meilisearch 검색 서비스 * - 일정 검색 (멤버 별명 → 이름 변환) * - 영문 자판 → 한글 변환 * - 유사도 필터링 * - 일정 동기화 */ import Inko from 'inko'; import config, { CATEGORY_IDS } from '../../config/index.js'; import { createLogger } from '../../utils/logger.js'; import { buildDatetime } from '../schedule.js'; const inko = new Inko(); const logger = createLogger('Meilisearch'); const INDEX_NAME = 'schedules'; const MIN_SCORE = config.meilisearch.minScore; // 캐시된 현재 활동 멤버 수 (동기화 시 갱신) let cachedActiveMemberCount = null; /** * 영문 자판으로 입력된 검색어인지 확인 */ 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); } /** * 현재 활동 멤버 수 조회 및 캐시 */ async function getActiveMemberCount(db) { if (cachedActiveMemberCount === null) { const [[{ count }]] = await db.query( 'SELECT COUNT(*) as count FROM members WHERE is_former = 0' ); cachedActiveMemberCount = count; } return cachedActiveMemberCount; } /** * 일정 검색 * @param {object} meilisearch - Meilisearch 클라이언트 * @param {object} db - DB 연결 풀 * @param {string} query - 검색어 * @param {object} options - 검색 옵션 (offset, limit for pagination) */ export async function searchSchedules(meilisearch, db, query, options = {}) { const { limit = 100, offset = 0 } = options; // 내부 검색 한도 (여러 검색어 병합 및 유사도 필터링 전 충분한 결과 확보) const SEARCH_LIMIT = 1000; try { // 현재 활동 멤버 수 캐시 (formatScheduleResponse에서 사용) await getActiveMemberCount(db); const index = meilisearch.index(INDEX_NAME); const searchOptions = { limit: SEARCH_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 }; } } /** * 검색 결과 응답 형식 변환 * schedule.js의 공통 포맷과 동일한 구조 반환 * (Meilisearch 인덱스 필드명이 다르므로 별도 매핑 필요) */ function formatScheduleResponse(hit) { // member_names를 배열로 변환 let members = hit.member_names ? hit.member_names.split(',').map(name => name.trim()).filter(Boolean) : []; // 전체 멤버인 경우 "프로미스나인"으로 대체 if (cachedActiveMemberCount && members.length === cachedActiveMemberCount) { members = ['프로미스나인']; } // source 객체 구성 (Meilisearch에는 URL 없음) 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: buildDatetime(hit.date, hit.time), category: { id: hit.category_id, name: hit.category_name, color: hit.category_color, }, source, members, }; } /** * 일정 추가/업데이트 */ 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 { // 현재 활동 멤버 수 캐시 갱신 const [[{ count }]] = await db.query( 'SELECT COUNT(*) as count FROM members WHERE is_former = 0' ); cachedActiveMemberCount = count; // 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 AND m.is_former = 0 GROUP BY s.id `); const index = meilisearch.index(INDEX_NAME); // 문서 변환 (addDocuments는 같은 ID면 자동 업데이트) 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; } }