250 lines
6.7 KiB
JavaScript
250 lines
6.7 KiB
JavaScript
|
|
/**
|
||
|
|
* Meilisearch 검색 서비스
|
||
|
|
* - 일정 검색 (멤버 별명 → 이름 변환)
|
||
|
|
* - 영문 자판 → 한글 변환
|
||
|
|
* - 유사도 필터링
|
||
|
|
* - 일정 동기화
|
||
|
|
*/
|
||
|
|
import Inko from 'inko';
|
||
|
|
|
||
|
|
const inko = new Inko();
|
||
|
|
const INDEX_NAME = 'schedules';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 영문 자판으로 입력된 검색어인지 확인
|
||
|
|
*/
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 유사도 0.5 미만 필터링
|
||
|
|
let filteredHits = Array.from(allHits.values())
|
||
|
|
.filter(hit => hit._rankingScore >= 0.5);
|
||
|
|
|
||
|
|
// 유사도 순 정렬
|
||
|
|
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) {
|
||
|
|
console.error('[Meilisearch] 검색 오류:', 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)
|
||
|
|
: [];
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: hit.id,
|
||
|
|
title: hit.title,
|
||
|
|
datetime,
|
||
|
|
category: {
|
||
|
|
id: hit.category_id,
|
||
|
|
name: hit.category_name,
|
||
|
|
color: hit.category_color,
|
||
|
|
},
|
||
|
|
source_name: hit.source_name || null,
|
||
|
|
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]);
|
||
|
|
console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`);
|
||
|
|
} catch (err) {
|
||
|
|
console.error('[Meilisearch] 문서 추가 오류:', err.message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 일정 삭제
|
||
|
|
*/
|
||
|
|
export async function deleteSchedule(meilisearch, scheduleId) {
|
||
|
|
try {
|
||
|
|
const index = meilisearch.index(INDEX_NAME);
|
||
|
|
await index.deleteDocument(scheduleId);
|
||
|
|
console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`);
|
||
|
|
} catch (err) {
|
||
|
|
console.error('[Meilisearch] 문서 삭제 오류:', 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);
|
||
|
|
console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`);
|
||
|
|
|
||
|
|
return documents.length;
|
||
|
|
} catch (err) {
|
||
|
|
console.error('[Meilisearch] 동기화 오류:', err.message);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
}
|