fromis_9/backend/src/services/meilisearch/index.js

264 lines
7.2 KiB
JavaScript
Raw Normal View History

/**
* 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 - 검색 옵션 (offset, limit for pagination)
*/
export async function searchSchedules(meilisearch, db, query, options = {}) {
const { limit = 100, offset = 0 } = options;
// 내부 검색 한도 (여러 검색어 병합 및 유사도 필터링 전 충분한 결과 확보)
const SEARCH_LIMIT = 1000;
try {
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 };
}
}
/**
* 검색 결과 응답 형식 변환
*/
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;
}
}